From 6ea7e51e7544e7b158302bab264c186685003022 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Fri, 8 May 2026 11:47:47 +0200 Subject: [PATCH] Show staged and unstaged git diffs --- src/client/src/appState.ts | 2 ++ src/client/src/components/PiWebApp.ts | 2 +- src/client/src/components/WorkspacePanel.ts | 22 +++++++++++++++---- src/client/src/components/shared.ts | 4 ++++ src/client/src/controllers/gitController.ts | 10 ++++++--- .../src/controllers/workspaceController.ts | 4 ++-- src/server/git/gitService.ts | 20 ++++++++++++----- 7 files changed, 49 insertions(+), 15 deletions(-) diff --git a/src/client/src/appState.ts b/src/client/src/appState.ts index fc34e42..a56dfc5 100644 --- a/src/client/src/appState.ts +++ b/src/client/src/appState.ts @@ -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: "", }; diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 32705ea..a935a29 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -173,7 +173,7 @@ export class PiWebApp extends LitElement { } private renderWorkspacePanel() { - return html` { 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)}>`; + return html` { 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)}>`; } private getActions(): AppAction[] { diff --git a/src/client/src/components/WorkspacePanel.ts b/src/client/src/components/WorkspacePanel.ts index 1db0501..86250c2 100644 --- a/src/client/src/components/WorkspacePanel.ts +++ b/src/client/src/components/WorkspacePanel.ts @@ -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`

Select a changed file.

`; - const diff = this.selectedDiff; - if (diff === undefined) return html`

Loading diff…

`; + const unstaged = this.selectedDiff; + const staged = this.selectedStagedDiff; + if (unstaged === undefined || staged === undefined) return html`

Loading diff…

`; + const diffs = [staged, unstaged].filter((diff) => diff.diff !== ""); + if (diffs.length === 0) return html`

No staged or unstaged diff.

`; return html` -
${diff.path ?? "diff"}${diff.staged ? "staged" : "unstaged"}${diff.truncated ? " · truncated" : ""}
- +
+ ${diffs.map((diff) => this.renderDiffSection(diff))} +
+ `; + } + + private renderDiffSection(diff: GitDiffResponse) { + return html` +
+
${diff.path ?? "diff"}${diff.staged ? "staged" : "unstaged"}${diff.truncated ? " · truncated" : ""}
+ +
`; } diff --git a/src/client/src/components/shared.ts b/src/client/src/components/shared.ts index 7eb1753..b8e991d 100644 --- a/src/client/src/components/shared.ts +++ b/src/client/src/components/shared.ts @@ -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; } diff --git a/src/client/src/controllers/gitController.ts b/src/client/src/controllers/gitController.ts index c31c113..708d51f 100644 --- a/src/client/src/controllers/gitController.ts +++ b/src/client/src/controllers/gitController.ts @@ -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 { - 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) }); } diff --git a/src/client/src/controllers/workspaceController.ts b/src/client/src/controllers/workspaceController.ts index 688324c..96096dd 100644 --- a/src/client/src/controllers/workspaceController.ts +++ b/src/client/src/controllers/workspaceController.ts @@ -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 }); diff --git a/src/server/git/gitService.ts b/src/server/git/gitService.ts index ba864a8..62d4a51 100644 --- a/src/server/git/gitService.ts +++ b/src/server/git/gitService.ts @@ -13,18 +13,28 @@ export async function gitStatus(cwd: string): Promise { export async function gitDiff(cwd: string, options: { path?: string; staged?: boolean }): Promise { 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 { + 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[] = [];