Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
55 commits
Select commit Hold shift + click to select a range
779c1d0
feat(desktop): add Workbench/Review mode with FileViewer panes
andreasasprou Dec 29, 2025
8287a78
fix(desktop): add worktreePath guards and fix stale state in tabs store
andreasasprou Dec 29, 2025
8efd836
fix(desktop): address CodeRabbit review - path traversal security fix…
andreasasprou Dec 29, 2025
e626899
fix(desktop): address PR review - security fixes and UX improvements
andreasasprou Dec 29, 2025
4319783
fix(desktop): address review - persistence schema and security fixes
andreasasprou Dec 29, 2025
a6eb083
fix(desktop): address critical review feedback - security and UX fixes
andreasasprou Dec 29, 2025
babc9fc
fix(desktop): critical security fix - validate worktreePath against l…
andreasasprou Dec 29, 2025
bd9c6dd
fix(desktop): security - validate worktreePath in all routes + preser…
andreasasprou Dec 29, 2025
64c791b
fix(desktop): security - path traversal in deleteUntracked + draft pr…
andreasasprou Dec 30, 2025
8b85b3b
refactor(desktop): simplify security code - remove overcomplicated pa…
andreasasprou Dec 30, 2025
998d363
fix(desktop): P0 untracked linecount + P2 terminal guard + branch safety
andreasasprou Dec 30, 2025
cdcb36e
refactor(desktop): security architecture overhaul with proper path va…
andreasasprou Dec 30, 2025
1b4a5a0
feat(desktop): add configurable terminal file link behavior
andreasasprou Dec 31, 2025
d8676b2
refactor(desktop): simplify security - remove symlink escape detection
andreasasprou Dec 31, 2025
9865aa1
feat(desktop): add configurable workspace navigation style (top-bar v…
andreasasprou Dec 31, 2025
31e7ef3
fix(desktop): P0 CreateWorkspaceButton in sidebar mode + P1 race cond…
andreasasprou Dec 31, 2025
08467fc
feat(desktop): workspace sidebar 1-1 feature parity with top-bar tabs
andreasasprou Dec 31, 2025
181ccb7
refactor(desktop): extract shared utilities for workspace components
andreasasprou Dec 31, 2025
a4fe475
refactor(desktop): move sidebar toggle to WorkspaceActionBar in sideb…
andreasasprou Dec 31, 2025
275104c
fix(desktop): prevent TopBar overlap with Mac traffic lights during load
andreasasprou Dec 31, 2025
6abe053
fix(desktop): increase Mac traffic light padding for better spacing
andreasasprou Dec 31, 2025
121e9fd
fix(desktop): address PR review feedback for changes security and ter…
andreasasprou Dec 31, 2025
6f140c9
feat(desktop): add 'Mark as Unread' context menu option for workspaces
andreasasprou Dec 31, 2025
f5213c3
fix(desktop): clear workspace attention when clicking workspace
andreasasprou Dec 31, 2025
df02cc1
fix(desktop): address second round of PR review feedback
andreasasprou Dec 31, 2025
cc2a3ac
fix(desktop): address third round of PR review feedback
andreasasprou Dec 31, 2025
0c072cf
feat(desktop): merge workspace action bar into top bar
andreasasprou Jan 2, 2026
fd7656a
fix(desktop): always show close/delete dialog for workspaces
andreasasprou Jan 2, 2026
a010e4f
fix(desktop): resolve TypeScript errors in workspace-sidebar branch
andreasasprou Jan 2, 2026
a44d74d
style(desktop): use border instead of bg for active tab in GroupStrip
andreasasprou Jan 2, 2026
e6eb772
style(desktop): use top/side borders for active tab in GroupStrip
andreasasprou Jan 2, 2026
4f15753
fix(desktop): update sidebar toggle tooltip to 'Toggle Changes Sidebar'
andreasasprou Jan 2, 2026
74a1eab
feat(desktop): add sidebar toggle to TabsContent in sidebar navigatio…
andreasasprou Jan 2, 2026
461a24d
fix(desktop): lift SidebarControl to ContentView to fix review mode r…
andreasasprou Jan 2, 2026
3681096
refactor(desktop): rename NEW_TERMINAL hotkey to NEW_GROUP for clarity
andreasasprou Jan 2, 2026
8795933
feat(desktop): move workspace controls to content header in sidebar mode
andreasasprou Jan 3, 2026
6a9850e
fix(desktop): harden file viewer security and improve terminal link h…
andreasasprou Jan 3, 2026
ef01450
fix(desktop): address PR review security and UX issues
andreasasprou Jan 3, 2026
b6c21eb
fix(desktop): use sep-aware check in assertParentInWorktree ENOENT path
andreasasprou Jan 3, 2026
4fe2110
fix(desktop): address P0/P1 security issues from review
andreasasprou Jan 3, 2026
4efee02
fix(desktop): guard against undefined panes in attention check
andreasasprou Jan 3, 2026
d068e92
fix(desktop): use strict allowlist for SafeImage - only allow data: URLs
andreasasprou Jan 3, 2026
8445492
fix(desktop): format aria-label ternary for biome compliance
andreasasprou Jan 4, 2026
04ea2a0
fix(desktop): address PR review feedback
andreasasprou Jan 4, 2026
15b4919
chore: remove outdated plan files
andreasasprou Jan 4, 2026
8a5f264
fix(desktop): reduce visual intensity of workspace sidebar
andreasasprou Jan 4, 2026
54f7d18
fix: lint
andreasasprou Jan 4, 2026
9f883e4
fix(desktop): add unsaved changes dialog when switching file viewer m…
andreasasprou Jan 4, 2026
57146dd
feat(desktop): add configurable group tabs position setting
andreasasprou Jan 4, 2026
15b0ce7
fix(desktop): address oracle review feedback for group tabs position
andreasasprou Jan 4, 2026
8953b28
chore: move completed group tabs position plan to done
andreasasprou Jan 4, 2026
95f4414
feat(desktop): add interactive plan viewer for AI agent plans
andreasasprou Jan 4, 2026
a1cd25c
docs(desktop): add detailed Phase 2/3 implementation plans for plan v…
andreasasprou Jan 4, 2026
8f03e10
docs(desktop): address Oracle review findings for plan viewer
andreasasprou Jan 4, 2026
f6eea24
wip approving via terminal
andreasasprou Jan 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3,212 changes: 3,212 additions & 0 deletions apps/desktop/.agents/plans/20260104-1105-interactive-plan-viewer.md

Large diffs are not rendered by default.

646 changes: 646 additions & 0 deletions apps/desktop/plans/done/20260104-1916-restore-group-tabs-sidebar.md

Large diffs are not rendered by default.

22 changes: 11 additions & 11 deletions apps/desktop/src/lib/trpc/routers/changes/branches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import { localDb } from "main/lib/local-db";
import simpleGit from "simple-git";
import { z } from "zod";
import { publicProcedure, router } from "../..";
import {
assertRegisteredWorktree,
getRegisteredWorktree,
gitSwitchBranch,
} from "./security";

export const createBranchesRouter = () => {
return router({
Expand All @@ -18,6 +23,8 @@ export const createBranchesRouter = () => {
defaultBranch: string;
checkedOutBranches: Record<string, string>;
}> => {
assertRegisteredWorktree(input.worktreePath);

const git = simpleGit(input.worktreePath);

const branchSummary = await git.branch(["-a"]);
Expand Down Expand Up @@ -59,18 +66,11 @@ export const createBranchesRouter = () => {
}),
)
.mutation(async ({ input }): Promise<{ success: boolean }> => {
const git = simpleGit(input.worktreePath);

const worktree = localDb
.select()
.from(worktrees)
.where(eq(worktrees.path, input.worktreePath))
.get();
if (!worktree) {
throw new Error(`No worktree found at path "${input.worktreePath}"`);
}
// Get worktree record for updating branch info
const worktree = getRegisteredWorktree(input.worktreePath);

await git.checkout(input.branch);
// Use gitSwitchBranch which uses `git switch` (correct branch syntax)
await gitSwitchBranch(input.worktreePath, input.branch);

// Update the branch in the worktree record
const gitStatus = worktree.gitStatus
Expand Down
204 changes: 146 additions & 58 deletions apps/desktop/src/lib/trpc/routers/changes/file-contents.ts
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
Expand All @@ -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;
Expand Down Expand Up @@ -50,10 +89,63 @@ export const createFileContentsRouter = () => {
}),
)
.mutation(async ({ input }): Promise<{ success: boolean }> => {
const fullPath = join(input.worktreePath, input.filePath);
await writeFile(fullPath, input.content, "utf-8");
// secureFs.writeFile validates worktree registration and path traversal
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 {
// Check file size first (uses stat which follows symlinks)
const stats = await secureFs.stat(input.worktreePath, input.filePath);
if (stats.size > MAX_FILE_SIZE) {
return { ok: false, reason: "too-large" };
}

// Read file content as buffer for binary detection
const buffer = await secureFs.readFileBuffer(
input.worktreePath,
input.filePath,
);

// Check for binary content
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) {
// Map specific error codes to distinct reasons
if (error.code === "SYMLINK_ESCAPE") {
return { ok: false, reason: "symlink-escape" };
}
return { ok: false, reason: "outside-worktree" };
}
// File not found or other read error
return { ok: false, reason: "not-found" };
}
}),
});
};

Expand Down Expand Up @@ -91,26 +183,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 "";
}
}

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 };
}
Expand All @@ -121,20 +228,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 };
}
Expand All @@ -144,20 +241,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 };
}
Expand All @@ -168,22 +255,23 @@ 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");
// Check file size before reading (uses stat which follows symlinks)
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 = "";
}

Expand Down
Loading