-
Notifications
You must be signed in to change notification settings - Fork 51
feat(desktop): add Workbench/Review mode + group tabs + workspace navigation sidebar #586
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
779c1d0
8287a78
8efd836
e626899
4319783
a6eb083
babc9fc
bd9c6dd
64c791b
8b85b3b
998d363
cdcb36e
1b4a5a0
d8676b2
9865aa1
31e7ef3
08467fc
181ccb7
a4fe475
275104c
6abe053
121e9fd
6f140c9
f5213c3
df02cc1
cc2a3ac
0c072cf
fd7656a
a010e4f
a44d74d
e6eb772
4f15753
74a1eab
461a24d
3681096
8795933
6a9850e
ef01450
b6c21eb
4fe2110
4efee02
d068e92
8445492
04ea2a0
15b4919
8a5f264
54f7d18
9f883e4
57146dd
15b0ce7
8953b28
7ec8d72
f38b552
136cc99
92254a3
e69a452
3c2b683
e5c9a04
dfc9bee
d04cac7
0b9576b
d8f815c
7c58526
3d17223
a0333b2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,11 +1,48 @@ | ||||||||||||||||||||||||||||||
| import { readFile, writeFile } from "node:fs/promises"; | ||||||||||||||||||||||||||||||
| import { join } from "node:path"; | ||||||||||||||||||||||||||||||
| import type { FileContents } from "shared/changes-types"; | ||||||||||||||||||||||||||||||
| import simpleGit from "simple-git"; | ||||||||||||||||||||||||||||||
| import { z } from "zod"; | ||||||||||||||||||||||||||||||
| import { publicProcedure, router } from "../.."; | ||||||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||||||
| assertRegisteredWorktree, | ||||||||||||||||||||||||||||||
| PathValidationError, | ||||||||||||||||||||||||||||||
| secureFs, | ||||||||||||||||||||||||||||||
| } from "./security"; | ||||||||||||||||||||||||||||||
| import { detectLanguage } from "./utils/parse-status"; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /** Maximum file size for reading (2 MiB) */ | ||||||||||||||||||||||||||||||
| const MAX_FILE_SIZE = 2 * 1024 * 1024; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /** Bytes to scan for binary detection */ | ||||||||||||||||||||||||||||||
| const BINARY_CHECK_SIZE = 8192; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||
| * Result type for readWorkingFile procedure | ||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||
| type ReadWorkingFileResult = | ||||||||||||||||||||||||||||||
| | { ok: true; content: string; truncated: boolean; byteLength: number } | ||||||||||||||||||||||||||||||
| | { | ||||||||||||||||||||||||||||||
| ok: false; | ||||||||||||||||||||||||||||||
| reason: | ||||||||||||||||||||||||||||||
| | "not-found" | ||||||||||||||||||||||||||||||
| | "too-large" | ||||||||||||||||||||||||||||||
| | "binary" | ||||||||||||||||||||||||||||||
| | "outside-worktree" | ||||||||||||||||||||||||||||||
| | "symlink-escape"; | ||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||
| * Detects if a buffer contains binary content by checking for NUL bytes | ||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||
| function isBinaryContent(buffer: Buffer): boolean { | ||||||||||||||||||||||||||||||
| const checkLength = Math.min(buffer.length, BINARY_CHECK_SIZE); | ||||||||||||||||||||||||||||||
| for (let i = 0; i < checkLength; i++) { | ||||||||||||||||||||||||||||||
| if (buffer[i] === 0) { | ||||||||||||||||||||||||||||||
| return true; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| return false; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| export const createFileContentsRouter = () => { | ||||||||||||||||||||||||||||||
| return router({ | ||||||||||||||||||||||||||||||
| getFileContents: publicProcedure | ||||||||||||||||||||||||||||||
|
|
@@ -20,6 +57,8 @@ export const createFileContentsRouter = () => { | |||||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
| .query(async ({ input }): Promise<FileContents> => { | ||||||||||||||||||||||||||||||
| assertRegisteredWorktree(input.worktreePath); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| const git = simpleGit(input.worktreePath); | ||||||||||||||||||||||||||||||
| const defaultBranch = input.defaultBranch || "main"; | ||||||||||||||||||||||||||||||
| const originalPath = input.oldPath || input.filePath; | ||||||||||||||||||||||||||||||
|
|
@@ -50,10 +89,57 @@ export const createFileContentsRouter = () => { | |||||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
| .mutation(async ({ input }): Promise<{ success: boolean }> => { | ||||||||||||||||||||||||||||||
| const fullPath = join(input.worktreePath, input.filePath); | ||||||||||||||||||||||||||||||
| await writeFile(fullPath, input.content, "utf-8"); | ||||||||||||||||||||||||||||||
| await secureFs.writeFile( | ||||||||||||||||||||||||||||||
| input.worktreePath, | ||||||||||||||||||||||||||||||
| input.filePath, | ||||||||||||||||||||||||||||||
| input.content, | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
| return { success: true }; | ||||||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||
| * Read a working tree file safely with size cap and binary detection. | ||||||||||||||||||||||||||||||
| * Used for File Viewer raw/rendered modes. | ||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||
| readWorkingFile: publicProcedure | ||||||||||||||||||||||||||||||
| .input( | ||||||||||||||||||||||||||||||
| z.object({ | ||||||||||||||||||||||||||||||
| worktreePath: z.string(), | ||||||||||||||||||||||||||||||
| filePath: z.string(), | ||||||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||
| .query(async ({ input }): Promise<ReadWorkingFileResult> => { | ||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||
| const stats = await secureFs.stat(input.worktreePath, input.filePath); | ||||||||||||||||||||||||||||||
| if (stats.size > MAX_FILE_SIZE) { | ||||||||||||||||||||||||||||||
| return { ok: false, reason: "too-large" }; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| const buffer = await secureFs.readFileBuffer( | ||||||||||||||||||||||||||||||
| input.worktreePath, | ||||||||||||||||||||||||||||||
| input.filePath, | ||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| if (isBinaryContent(buffer)) { | ||||||||||||||||||||||||||||||
| return { ok: false, reason: "binary" }; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||
| ok: true, | ||||||||||||||||||||||||||||||
| content: buffer.toString("utf-8"), | ||||||||||||||||||||||||||||||
| truncated: false, | ||||||||||||||||||||||||||||||
| byteLength: buffer.length, | ||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||
| } catch (error) { | ||||||||||||||||||||||||||||||
| if (error instanceof PathValidationError) { | ||||||||||||||||||||||||||||||
| if (error.code === "SYMLINK_ESCAPE") { | ||||||||||||||||||||||||||||||
| return { ok: false, reason: "symlink-escape" }; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| return { ok: false, reason: "outside-worktree" }; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| return { ok: false, reason: "not-found" }; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| }), | ||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
@@ -91,26 +177,41 @@ async function getFileVersions( | |||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| /** Helper to safely get git show content with size limit and memory protection */ | ||||||||||||||||||||||||||||||
| async function safeGitShow( | ||||||||||||||||||||||||||||||
| git: ReturnType<typeof simpleGit>, | ||||||||||||||||||||||||||||||
| spec: string, | ||||||||||||||||||||||||||||||
| ): Promise<string> { | ||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||
| // Preflight: check blob size before loading into memory | ||||||||||||||||||||||||||||||
| // This prevents memory spikes from large files in git history | ||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||
| const sizeOutput = await git.raw(["cat-file", "-s", spec]); | ||||||||||||||||||||||||||||||
| const blobSize = Number.parseInt(sizeOutput.trim(), 10); | ||||||||||||||||||||||||||||||
| if (!Number.isNaN(blobSize) && blobSize > MAX_FILE_SIZE) { | ||||||||||||||||||||||||||||||
| return `[File content truncated - exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit]`; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||
| // cat-file failed (blob doesn't exist) - let git.show handle the error | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| const content = await git.show([spec]); | ||||||||||||||||||||||||||||||
| return content; | ||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||
| return ""; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
Comment on lines
+198
to
+202
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Log git.show errors before returning empty string. Returning an empty string without logging when 🔎 Proposed fix to add error logging const content = await git.show([spec]);
return content;
- } catch {
+ } catch (error) {
+ console.error(
+ `[file-contents/safeGitShow] Failed to fetch ${spec}:`,
+ error,
+ );
return "";
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| async function getAgainstBaseVersions( | ||||||||||||||||||||||||||||||
| git: ReturnType<typeof simpleGit>, | ||||||||||||||||||||||||||||||
| filePath: string, | ||||||||||||||||||||||||||||||
| originalPath: string, | ||||||||||||||||||||||||||||||
| defaultBranch: string, | ||||||||||||||||||||||||||||||
| ): Promise<FileVersions> { | ||||||||||||||||||||||||||||||
| let original = ""; | ||||||||||||||||||||||||||||||
| let modified = ""; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||
| original = await git.show([`origin/${defaultBranch}:${originalPath}`]); | ||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||
| original = ""; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||
| modified = await git.show([`HEAD:${filePath}`]); | ||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||
| modified = ""; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| const [original, modified] = await Promise.all([ | ||||||||||||||||||||||||||||||
| safeGitShow(git, `origin/${defaultBranch}:${originalPath}`), | ||||||||||||||||||||||||||||||
| safeGitShow(git, `HEAD:${filePath}`), | ||||||||||||||||||||||||||||||
| ]); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| return { original, modified }; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
@@ -121,20 +222,10 @@ async function getCommittedVersions( | |||||||||||||||||||||||||||||
| originalPath: string, | ||||||||||||||||||||||||||||||
| commitHash: string, | ||||||||||||||||||||||||||||||
| ): Promise<FileVersions> { | ||||||||||||||||||||||||||||||
| let original = ""; | ||||||||||||||||||||||||||||||
| let modified = ""; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||
| original = await git.show([`${commitHash}^:${originalPath}`]); | ||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||
| original = ""; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||
| modified = await git.show([`${commitHash}:${filePath}`]); | ||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||
| modified = ""; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| const [original, modified] = await Promise.all([ | ||||||||||||||||||||||||||||||
| safeGitShow(git, `${commitHash}^:${originalPath}`), | ||||||||||||||||||||||||||||||
| safeGitShow(git, `${commitHash}:${filePath}`), | ||||||||||||||||||||||||||||||
| ]); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| return { original, modified }; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
@@ -144,20 +235,10 @@ async function getStagedVersions( | |||||||||||||||||||||||||||||
| filePath: string, | ||||||||||||||||||||||||||||||
| originalPath: string, | ||||||||||||||||||||||||||||||
| ): Promise<FileVersions> { | ||||||||||||||||||||||||||||||
| let original = ""; | ||||||||||||||||||||||||||||||
| let modified = ""; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||
| original = await git.show([`HEAD:${originalPath}`]); | ||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||
| original = ""; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||
| modified = await git.show([`:0:${filePath}`]); | ||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||
| modified = ""; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| const [original, modified] = await Promise.all([ | ||||||||||||||||||||||||||||||
| safeGitShow(git, `HEAD:${originalPath}`), | ||||||||||||||||||||||||||||||
| safeGitShow(git, `:0:${filePath}`), | ||||||||||||||||||||||||||||||
| ]); | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| return { original, modified }; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
@@ -168,22 +249,22 @@ async function getUnstagedVersions( | |||||||||||||||||||||||||||||
| filePath: string, | ||||||||||||||||||||||||||||||
| originalPath: string, | ||||||||||||||||||||||||||||||
| ): Promise<FileVersions> { | ||||||||||||||||||||||||||||||
| let original = ""; | ||||||||||||||||||||||||||||||
| let modified = ""; | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||
| original = await git.show([`:0:${originalPath}`]); | ||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||
| original = await git.show([`HEAD:${originalPath}`]); | ||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||
| original = ""; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| // Try staged version first, fall back to HEAD | ||||||||||||||||||||||||||||||
| let original = await safeGitShow(git, `:0:${originalPath}`); | ||||||||||||||||||||||||||||||
| if (!original) { | ||||||||||||||||||||||||||||||
| original = await safeGitShow(git, `HEAD:${originalPath}`); | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
| let modified = ""; | ||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||
| modified = await readFile(join(worktreePath, filePath), "utf-8"); | ||||||||||||||||||||||||||||||
| const stats = await secureFs.stat(worktreePath, filePath); | ||||||||||||||||||||||||||||||
| if (stats.size <= MAX_FILE_SIZE) { | ||||||||||||||||||||||||||||||
| modified = await secureFs.readFile(worktreePath, filePath); | ||||||||||||||||||||||||||||||
| } else { | ||||||||||||||||||||||||||||||
| modified = `[File content truncated - exceeds ${MAX_FILE_SIZE / 1024 / 1024}MB limit]`; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||||||||
| // File doesn't exist or validation failed - that's ok for diff display | ||||||||||||||||||||||||||||||
| modified = ""; | ||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Log errors before mapping to generic failure reason.
The catch-all error handler maps any unhandled exception to
"not-found"without logging. This violates the coding guideline "Never swallow errors silently - at minimum log them with context" and can hide permission errors, I/O failures, or other unexpected issues that would be valuable for debugging.🔎 Proposed fix to add error logging
} catch (error) { if (error instanceof PathValidationError) { if (error.code === "SYMLINK_ESCAPE") { return { ok: false, reason: "symlink-escape" }; } return { ok: false, reason: "outside-worktree" }; } + console.error( + "[file-contents/readWorkingFile] Unexpected error:", + error, + ); return { ok: false, reason: "not-found" }; }📝 Committable suggestion
🤖 Prompt for AI Agents