Show staged and unstaged git diffs

This commit is contained in:
Federico Jaramillo Martinez
2026-05-08 11:47:47 +02:00
parent 86b8a25d2c
commit 6ea7e51e75
7 changed files with 49 additions and 15 deletions
+2
View File
@@ -28,6 +28,7 @@ export interface AppState {
gitStatus: GitStatusResponse | undefined;
selectedDiffPath: string | undefined;
selectedDiff: GitDiffResponse | undefined;
selectedStagedDiff: GitDiffResponse | undefined;
gitStale: boolean;
error: string;
}
@@ -60,6 +61,7 @@ export function initialAppState(): AppState {
gitStatus: undefined,
selectedDiffPath: undefined,
selectedDiff: undefined,
selectedStagedDiff: undefined,
gitStale: false,
error: "",
};
+1 -1
View File
@@ -173,7 +173,7 @@ export class PiWebApp extends LitElement {
}
private renderWorkspacePanel() {
return html`<workspace-panel .workspace=${this.state.selectedWorkspace} .tool=${this.state.workspaceTool} .fileTree=${this.state.fileTree} .expandedDirs=${this.state.expandedDirs} .selectedFilePath=${this.state.selectedFilePath} .selectedFileContent=${this.state.selectedFileContent} .fileTreeStale=${this.state.fileTreeStale} .gitStatus=${this.state.gitStatus} .selectedDiffPath=${this.state.selectedDiffPath} .selectedDiff=${this.state.selectedDiff} .gitStale=${this.state.gitStale} .onSelectTool=${(tool: "files" | "git") => { this.selectWorkspaceTool(tool); }} .onRefreshFiles=${() => this.files.refreshFiles()} .onExpandDir=${(path: string) => this.files.expandDir(path)} .onSelectFile=${(path: string) => this.files.selectFile(path)} .onRefreshGit=${() => this.git.refreshGit()} .onSelectDiff=${(path: string) => this.git.selectDiff(path)}></workspace-panel>`;
return html`<workspace-panel .workspace=${this.state.selectedWorkspace} .tool=${this.state.workspaceTool} .fileTree=${this.state.fileTree} .expandedDirs=${this.state.expandedDirs} .selectedFilePath=${this.state.selectedFilePath} .selectedFileContent=${this.state.selectedFileContent} .fileTreeStale=${this.state.fileTreeStale} .gitStatus=${this.state.gitStatus} .selectedDiffPath=${this.state.selectedDiffPath} .selectedDiff=${this.state.selectedDiff} .selectedStagedDiff=${this.state.selectedStagedDiff} .gitStale=${this.state.gitStale} .onSelectTool=${(tool: "files" | "git") => { this.selectWorkspaceTool(tool); }} .onRefreshFiles=${() => this.files.refreshFiles()} .onExpandDir=${(path: string) => this.files.expandDir(path)} .onSelectFile=${(path: string) => this.files.selectFile(path)} .onRefreshGit=${() => this.git.refreshGit()} .onSelectDiff=${(path: string) => this.git.selectDiff(path)}></workspace-panel>`;
}
private getActions(): AppAction[] {
+18 -4
View File
@@ -16,6 +16,7 @@ export class WorkspacePanel extends LitElement {
@property({ attribute: false }) gitStatus: GitStatusResponse | undefined;
@property({ attribute: false }) selectedDiffPath: string | undefined;
@property({ attribute: false }) selectedDiff: GitDiffResponse | undefined;
@property({ attribute: false }) selectedStagedDiff: GitDiffResponse | undefined;
@property({ type: Boolean }) gitStale = false;
@property({ attribute: false }) onSelectTool: (tool: "files" | "git") => void = () => undefined;
@property({ attribute: false }) onRefreshFiles: () => void = () => undefined;
@@ -113,11 +114,24 @@ export class WorkspacePanel extends LitElement {
private renderDiffViewer() {
if (this.selectedDiffPath === undefined || this.selectedDiffPath === "") return html`<p class="muted">Select a changed file.</p>`;
const diff = this.selectedDiff;
if (diff === undefined) return html`<p class="muted">Loading diff…</p>`;
const unstaged = this.selectedDiff;
const staged = this.selectedStagedDiff;
if (unstaged === undefined || staged === undefined) return html`<p class="muted">Loading diff…</p>`;
const diffs = [staged, unstaged].filter((diff) => diff.diff !== "");
if (diffs.length === 0) return html`<p class="muted">No staged or unstaged diff.</p>`;
return html`
<div class="viewer-header"><strong>${diff.path ?? "diff"}</strong><small>${diff.staged ? "staged" : "unstaged"}${diff.truncated ? " · truncated" : ""}</small></div>
<code-viewer .content=${diff.diff !== "" ? diff.diff : "No unstaged diff."} .language=${"diff"}></code-viewer>
<div class=${diffs.length === 1 ? "diffs single" : "diffs"}>
${diffs.map((diff) => this.renderDiffSection(diff))}
</div>
`;
}
private renderDiffSection(diff: GitDiffResponse) {
return html`
<section class="diff-section">
<div class="viewer-header"><strong>${diff.path ?? "diff"}</strong><small>${diff.staged ? "staged" : "unstaged"}${diff.truncated ? " · truncated" : ""}</small></div>
<code-viewer .content=${diff.diff} .language=${"diff"}></code-viewer>
</section>
`;
}
+4
View File
@@ -73,6 +73,10 @@ export const workspacePanelStyles = css`
.row span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.summary { margin: 4px 6px 8px; color: #8b949e; }
.viewer { min-height: 0; overflow: hidden; display: flex; flex-direction: column; }
.diffs { flex: 1 1 auto; min-height: 0; overflow: auto; display: grid; grid-template-rows: minmax(120px, 1fr) minmax(120px, 1fr); }
.diffs.single { grid-template-rows: minmax(0, 1fr); }
.diff-section { min-height: 0; display: flex; flex-direction: column; border-bottom: 1px solid #30363d; }
.diff-section:last-child { border-bottom: 0; }
.viewer-header { position: sticky; top: 0; display: flex; justify-content: space-between; gap: 8px; padding: 8px; border-bottom: 1px solid #21262d; background: #0d1117; }
.viewer-header strong { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
code-viewer { flex: 1 1 auto; min-height: 0; }
+7 -3
View File
@@ -22,7 +22,7 @@ export class GitController {
if (selectedDiffPath !== undefined) {
if (status.files.some((file) => file.path === selectedDiffPath)) await this.refreshDiff(selectedDiffPath);
else {
this.setState({ selectedDiffPath: undefined, selectedDiff: undefined });
this.setState({ selectedDiffPath: undefined, selectedDiff: undefined, selectedStagedDiff: undefined });
this.updateUrl();
}
}
@@ -32,7 +32,7 @@ export class GitController {
}
async selectDiff(path: string): Promise<void> {
this.setState({ selectedDiffPath: path, selectedDiff: undefined, workspaceTool: "git", mainView: this.getState().mainView === "chat" ? "chat" : "git" });
this.setState({ selectedDiffPath: path, selectedDiff: undefined, selectedStagedDiff: undefined, workspaceTool: "git", mainView: this.getState().mainView === "chat" ? "chat" : "git" });
this.updateUrl();
await this.refreshDiff(path);
}
@@ -42,7 +42,11 @@ export class GitController {
const workspace = this.getState().selectedWorkspace;
if (project === undefined || workspace === undefined) return;
try {
this.setState({ selectedDiff: await api.gitDiff(project.id, workspace.id, { path }), error: "" });
const [selectedDiff, selectedStagedDiff] = await Promise.all([
api.gitDiff(project.id, workspace.id, { path }),
api.gitDiff(project.id, workspace.id, { path, staged: true }),
]);
this.setState({ selectedDiff, selectedStagedDiff, error: "" });
} catch (error) {
this.setState({ error: String(error) });
}
@@ -12,7 +12,7 @@ export class WorkspaceController {
async selectProject(project: Project, target?: RouteTarget) {
this.sessions.clearActiveSession();
this.setState({ selectedProject: project, selectedWorkspace: undefined, sessions: [], workspaces: [], fileTree: [], expandedDirs: {}, selectedFilePath: undefined, selectedFileContent: undefined, fileTreeStale: false, gitStatus: undefined, selectedDiffPath: undefined, selectedDiff: undefined, gitStale: false, error: "" });
this.setState({ selectedProject: project, selectedWorkspace: undefined, sessions: [], workspaces: [], fileTree: [], expandedDirs: {}, selectedFilePath: undefined, selectedFileContent: undefined, fileTreeStale: false, gitStatus: undefined, selectedDiffPath: undefined, selectedDiff: undefined, selectedStagedDiff: undefined, gitStale: false, error: "" });
try {
const workspaces = await api.workspaces(project.id);
this.setState({ workspaces });
@@ -26,7 +26,7 @@ export class WorkspaceController {
async selectWorkspace(workspace: Workspace, target?: { sessionId?: string | undefined; updateUrl?: boolean | undefined }) {
this.sessions.clearActiveSession();
this.setState({ selectedWorkspace: workspace, sessions: [], fileTree: [], expandedDirs: {}, selectedFilePath: undefined, selectedFileContent: undefined, fileTreeStale: false, gitStatus: undefined, selectedDiffPath: undefined, selectedDiff: undefined, gitStale: false, error: "" });
this.setState({ selectedWorkspace: workspace, sessions: [], fileTree: [], expandedDirs: {}, selectedFilePath: undefined, selectedFileContent: undefined, fileTreeStale: false, gitStatus: undefined, selectedDiffPath: undefined, selectedDiff: undefined, selectedStagedDiff: undefined, gitStale: false, error: "" });
try {
const sessions = await api.sessions(workspace.path);
this.setState({ sessions });
+15 -5
View File
@@ -13,18 +13,28 @@ export async function gitStatus(cwd: string): Promise<GitStatusResponse> {
export async function gitDiff(cwd: string, options: { path?: string; staged?: boolean }): Promise<GitDiffResponse> {
const staged = options.staged === true;
let path: string | undefined;
if (options.path !== undefined && options.path !== "") path = normalizeRelativePath(options.path);
const args = ["diff", "--no-ext-diff", "--color=never"];
if (staged) args.push("--cached");
let path: string | undefined;
if (options.path !== undefined && options.path !== "") {
path = normalizeRelativePath(options.path);
args.push("--", path);
}
if (path !== undefined) args.push("--", path);
const result = await runGit(cwd, args);
if (result.code !== 0) throw new Error(result.stderr.trim() || "git diff failed");
if (!staged && path !== undefined && result.stdout === "" && await isUntracked(cwd, path)) {
const untracked = await runGit(cwd, ["diff", "--no-ext-diff", "--color=never", "--no-index", "/dev/null", "--", path]);
if (untracked.code !== 0 && untracked.code !== 1) throw new Error(untracked.stderr.trim() || "git diff failed");
return { path, staged, hash: hash(untracked.stdout), diff: untracked.stdout, truncated: untracked.truncated };
}
return { ...(path === undefined ? {} : { path }), staged, hash: hash(result.stdout), diff: result.stdout, truncated: result.truncated };
}
async function isUntracked(cwd: string, path: string): Promise<boolean> {
const result = await runGit(cwd, ["ls-files", "--others", "--exclude-standard", "-z", "--", path]);
return result.code === 0 && result.stdout.split("\0").includes(path);
}
function parseStatus(raw: string): GitStatusResponse {
const records = raw.split("\0").filter((record) => record !== "");
const files: GitStatusFile[] = [];