Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
e7a3779
feat: add per-project worktree mode setting
z3thon Mar 17, 2026
1cf4a0f
feat(desktop): improve worktree optional UX and add workspace close
z3thon Mar 19, 2026
6867bd2
fix(desktop): worktree recycle uses Trash, sidebar refreshes on proje…
z3thon Mar 19, 2026
60c9f87
feat(desktop): add hover shortcut overlay for branch-only projects
z3thon Mar 19, 2026
5c8d783
feat(desktop): add agent status ring to branch-only project thumbnails
z3thon Mar 19, 2026
f878bcd
feat(desktop): auto-discover project favicon on open
z3thon Mar 19, 2026
797c226
fix(desktop): match status ring radius to project thumbnail (rounded)
z3thon Mar 19, 2026
6f86de9
fix(desktop): expand favicon discovery to find nested app icons
z3thon Mar 19, 2026
8dfa3ce
style(desktop): workspace count in badge tile instead of parentheses
z3thon Mar 19, 2026
5b9a80f
fix(desktop): clear review status when clicking branch-only project
z3thon Mar 19, 2026
3ecf401
fix(desktop): show worktree choice dialog on drag-and-drop project open
z3thon Mar 19, 2026
93a5305
fix(desktop): navigate to workspace after drag-and-drop project open
z3thon Mar 19, 2026
d30adb6
feat: consolidate project settings into a dedicated Projects page
z3thon Mar 17, 2026
c9f3bcb
feat(desktop): enhance projects page with workspace listings
z3thon Mar 19, 2026
44c055f
feat(desktop): polish projects settings page
z3thon Mar 19, 2026
433d3d7
fix: deduplicate allGroups query after rebase
z3thon Mar 19, 2026
85d7416
feat(desktop): project icons in settings list + detect/refresh button
z3thon Mar 19, 2026
1d49c37
fix(desktop): remove icon Remove button, keep Detect and Upload only
z3thon Mar 19, 2026
028c388
fix(desktop): show Remove button when any icon is set
z3thon Mar 19, 2026
cb8f280
fix(desktop): only show Remove icon button after manual upload
z3thon Mar 19, 2026
dfd7cea
fix(desktop): swap Detect/Remove visibility based on upload state
z3thon Mar 19, 2026
7bd787a
feat: add file tree as a draggable pane type
z3thon Mar 17, 2026
534c2f6
fix(desktop): wrap FileTreePane toolbar in div for react-dnd compat
z3thon Mar 19, 2026
b758f1f
feat(desktop): movable sidebar panels and expand button relocation
z3thon Mar 19, 2026
2784f60
Merge remote-tracking branch 'upstream/main' into feat/per-project-wo…
z3thon Mar 19, 2026
48da360
fix(desktop): address PR review feedback
z3thon Mar 19, 2026
869fd78
Merge branch 'feat/per-project-worktree-mode' into feat/projects-sett…
z3thon Mar 19, 2026
6a594a2
fix(desktop): address PR review feedback for file-tree-pane
z3thon Mar 19, 2026
1fef62b
feat(desktop): independent left/right panel toggles with dedicated bu…
z3thon Mar 19, 2026
96d15b2
feat(desktop): move panel toggle buttons to top nav bar
z3thon Mar 19, 2026
f9a6c6c
fix(desktop): restore split-pane project settings page lost during merge
z3thon Mar 20, 2026
257a1ba
Merge branch 'feat/projects-settings-list' into feat/file-tree-pane
z3thon Mar 20, 2026
2417163
fix(desktop): remove duplicate Projects section from settings sidebar…
z3thon Mar 20, 2026
4f5d3e9
Merge branch 'feat/projects-settings-list' into feat/file-tree-pane
z3thon Mar 20, 2026
b824b4b
fix(desktop): remove back button from project settings (now inline in…
z3thon Mar 20, 2026
954595f
Merge branch 'feat/projects-settings-list' into feat/file-tree-pane
z3thon Mar 20, 2026
d34f945
feat(desktop): match panel toggle buttons to existing sidebar toggle …
z3thon Mar 20, 2026
c4a0324
fix(desktop): X button on workspace uses close dialog with recycle op…
z3thon Mar 20, 2026
4d6c419
Merge branch 'feat/per-project-worktree-mode' into feat/projects-sett…
z3thon Mar 20, 2026
9409aae
Merge branch 'feat/projects-settings-list' into feat/file-tree-pane
z3thon Mar 20, 2026
3e531b8
Merge remote-tracking branch 'upstream/main' into feat/per-project-wo…
z3thon Mar 20, 2026
cdd1ff4
Merge branch 'feat/per-project-worktree-mode' into feat/projects-sett…
z3thon Mar 20, 2026
41184a4
Merge branch 'feat/projects-settings-list' into feat/file-tree-pane
z3thon Mar 20, 2026
7b18481
Merge remote-tracking branch 'upstream/main' into feat/per-project-wo…
z3thon Mar 20, 2026
664570c
Merge branch 'feat/per-project-worktree-mode' into feat/projects-sett…
z3thon Mar 20, 2026
03d7fab
Merge branch 'feat/projects-settings-list' into feat/file-tree-pane
z3thon Mar 20, 2026
d14d19d
fix(desktop): add missing settings import in workspace create procedure
z3thon Mar 20, 2026
16f2b70
Merge branch 'feat/per-project-worktree-mode' into feat/projects-sett…
z3thon Mar 20, 2026
798c166
Merge branch 'feat/projects-settings-list' into feat/file-tree-pane
z3thon Mar 20, 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
101 changes: 100 additions & 1 deletion apps/desktop/src/lib/trpc/routers/projects/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
projects,
type SelectProject,
settings,
WORKTREE_MODES,
workspaceSections,
workspaces,
worktrees,
Expand Down Expand Up @@ -116,6 +117,27 @@ function upsertProject(mainRepoPath: string, defaultBranch: string): Project {
.set({ lastOpenedAt: Date.now(), defaultBranch })
.where(eq(projects.id, existing.id))
.run();

// Discover favicon if no icon is set yet (fire-and-forget)
if (!existing.iconUrl) {
discoverAndSaveProjectIcon({
projectId: existing.id,
repoPath: mainRepoPath,
})
.then((iconUrl) => {
if (iconUrl) {
localDb
.update(projects)
.set({ iconUrl })
.where(eq(projects.id, existing.id))
.run();
}
})
.catch((err) => {
console.error("[upsertProject] Favicon discovery failed:", err);
});
}

return { ...existing, lastOpenedAt: Date.now(), defaultBranch };
}

Expand All @@ -130,6 +152,24 @@ function upsertProject(mainRepoPath: string, defaultBranch: string): Project {
.returning()
.get();

// Discover favicon for new project (fire-and-forget)
discoverAndSaveProjectIcon({
projectId: project.id,
repoPath: mainRepoPath,
})
.then((iconUrl) => {
if (iconUrl) {
localDb
.update(projects)
.set({ iconUrl })
.where(eq(projects.id, project.id))
.run();
}
})
.catch((err) => {
console.error("[upsertProject] Favicon discovery failed:", err);
});

return project;
}

Expand Down Expand Up @@ -1230,6 +1270,7 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => {
worktreeBaseDir: z.string().nullable().optional(),
hideImage: z.boolean().optional(),
defaultApp: z.enum(EXTERNAL_APPS).nullable().optional(),
worktreeMode: z.enum(WORKTREE_MODES).nullable().optional(),
}),
}),
)
Expand Down Expand Up @@ -1268,6 +1309,9 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => {
...(input.patch.defaultApp !== undefined && {
defaultApp: input.patch.defaultApp,
}),
...(input.patch.worktreeMode !== undefined && {
worktreeMode: input.patch.worktreeMode,
}),
lastOpenedAt: Date.now(),
})
.where(eq(projects.id, input.id))
Expand Down Expand Up @@ -1366,7 +1410,12 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => {
}),

close: publicProcedure
.input(z.object({ id: z.string() }))
.input(
z.object({
id: z.string(),
deleteWorktrees: z.boolean().optional().default(false),
}),
)
.mutation(async ({ input }) => {
const project = localDb
.select()
Expand Down Expand Up @@ -1394,6 +1443,56 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => {

const closedWorkspaceIds = projectWorkspaces.map((w) => w.id);

// Optionally move worktree directories to Trash
if (input.deleteWorktrees) {
const { existsSync } = await import("node:fs");
const { shell } = await import("electron");

// Collect worktree paths from both the worktrees table and workspace records
const projectWorktrees = localDb
.select()
.from(worktrees)
.where(eq(worktrees.projectId, input.id))
.all();

const worktreePaths = new Set<string>(
projectWorktrees.map((wt) => wt.path),
);

for (const ws of projectWorkspaces) {
if (ws.type === "worktree" && ws.worktreeId) {
const wt = localDb
.select()
.from(worktrees)
.where(eq(worktrees.id, ws.worktreeId))
.get();
if (wt?.path) {
worktreePaths.add(wt.path);
}
}
}

for (const wtPath of worktreePaths) {
if (!existsSync(wtPath)) continue;
try {
await shell.trashItem(wtPath);
} catch (error) {
console.error(
`[projects/close] Failed to trash worktree ${wtPath}:`,
error,
);
}
}

// Clean up stale git worktree references
try {
const git = await getSimpleGitWithShellPath(project.mainRepoPath);
await git.raw(["worktree", "prune"]);
} catch (error) {
console.error("[projects/close] Failed to prune worktrees:", error);
}
}

if (closedWorkspaceIds.length > 0) {
localDb
.delete(workspaces)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import {
saveProjectIconFromFile,
} from "main/lib/project-icons";

/** Common favicon file names to search for in project roots */
/** Common favicon file names to search for in project roots and subdirectories */
const FAVICON_PATTERNS = [
// Root level
"favicon.ico",
"favicon.png",
"favicon.svg",
Expand All @@ -17,6 +18,7 @@ const FAVICON_PATTERNS = [
"icon.svg",
".github/logo.png",
".github/logo.svg",
// Common static/public directories
"public/favicon.ico",
"public/favicon.png",
"public/favicon.svg",
Expand All @@ -28,6 +30,19 @@ const FAVICON_PATTERNS = [
"assets/favicon.ico",
"assets/favicon.png",
"assets/icon.png",
// Next.js / app directory patterns (up to 2 levels deep)
"app/favicon.ico",
"app/icon.png",
"app/icon.svg",
"**/app/favicon.ico",
"**/app/icon.png",
"**/app/icon.svg",
// Deeper public directories in monorepos/nested projects
"**/public/favicon.ico",
"**/public/favicon.png",
"**/public/favicon.svg",
"**/public/logo.png",
"**/public/logo.svg",
];

/** Max file size for discovered favicons: 256KB */
Expand All @@ -48,6 +63,7 @@ export async function discoverAndSaveProjectIcon({
const matches = await fg(FAVICON_PATTERNS, {
cwd: repoPath,
absolute: true,
deep: 4,
ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**", "**/build/**"],
onlyFiles: true,
});
Expand Down
21 changes: 21 additions & 0 deletions apps/desktop/src/lib/trpc/routers/settings/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
settings,
TERMINAL_LINK_BEHAVIORS,
type TerminalPreset,
WORKTREE_MODES,
} from "@superset/local-db";
import {
AGENT_PRESET_COMMANDS,
Expand Down Expand Up @@ -750,6 +751,26 @@ export const createSettingsRouter = () => {
return { success: true };
}),

getWorktreeMode: publicProcedure.query(() => {
const row = getSettings();
return row.worktreeMode ?? "always";
}),

setWorktreeMode: publicProcedure
.input(z.object({ mode: z.enum(WORKTREE_MODES) }))
.mutation(({ input }) => {
localDb
.insert(settings)
.values({ id: 1, worktreeMode: input.mode })
.onConflictDoUpdate({
target: settings.id,
set: { worktreeMode: input.mode },
})
.run();

return { success: true };
}),

getOpenLinksInApp: publicProcedure.query(() => {
const row = getSettings();
return row.openLinksInApp ?? DEFAULT_OPEN_LINKS_IN_APP;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { projects, workspaces, worktrees } from "@superset/local-db";
import { projects, settings, workspaces, worktrees } from "@superset/local-db";
import { and, eq, isNull, not } from "drizzle-orm";
import { track } from "main/lib/analytics";
import { localDb } from "main/lib/local-db";
Expand Down Expand Up @@ -294,6 +294,7 @@ export const createCreateProcedures = () => {
baseBranch: z.string().optional(),
useExistingBranch: z.boolean().optional(),
applyPrefix: z.boolean().optional().default(true),
useWorktree: z.boolean().optional(),
}),
)
.mutation(async ({ input }) => {
Expand All @@ -306,6 +307,92 @@ export const createCreateProcedures = () => {
throw new Error(`Project ${input.projectId} not found`);
}

// Resolve effective worktree mode
const globalSettings = localDb.select().from(settings).get();
const effectiveWorktreeMode =
project.worktreeMode ?? globalSettings?.worktreeMode ?? "always";

// If worktrees are disabled (or user explicitly chose no worktree),
// open directly in the main repo
if (
effectiveWorktreeMode === "disabled" ||
input.useWorktree === false
) {
const branch =
input.branchName?.trim() ||
(await getCurrentBranch(project.mainRepoPath));
if (!branch) {
throw new Error("Could not determine current branch");
}

// Checkout the target branch so the main repo matches the workspace
if (input.branchName?.trim()) {
await safeCheckoutBranch(project.mainRepoPath, branch);
}

const existing = getBranchWorkspace(input.projectId);

if (existing) {
if (existing.branch !== branch) {
localDb
.update(workspaces)
.set({ branch })
.where(eq(workspaces.id, existing.id))
.run();
}
touchWorkspace(existing.id);
setLastActiveWorkspace(existing.id);
return {
workspace: {
...existing,
branch,
lastOpenedAt: Date.now(),
},
worktreePath: project.mainRepoPath,
projectId: project.id,
isInitializing: false,
wasExisting: true,
};
Comment on lines +345 to +355
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Inconsistent return shape: initialCommands missing for existing workspace path.

The existing workspace return (lines 346-356) omits initialCommands, while the new workspace return (lines 387-394) includes initialCommands: null. This inconsistency could cause issues if the client expects a uniform return shape.

Proposed fix: Add `initialCommands: null` for consistency
          return {
            workspace: {
              ...existing,
              branch,
              lastOpenedAt: Date.now(),
            },
            worktreePath: project.mainRepoPath,
            projectId: project.id,
+           initialCommands: null,
            isInitializing: false,
            wasExisting: true,
          };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
return {
workspace: {
...existing,
branch,
lastOpenedAt: Date.now(),
},
worktreePath: project.mainRepoPath,
projectId: project.id,
isInitializing: false,
wasExisting: true,
};
return {
workspace: {
...existing,
branch,
lastOpenedAt: Date.now(),
},
worktreePath: project.mainRepoPath,
projectId: project.id,
initialCommands: null,
isInitializing: false,
wasExisting: true,
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/lib/trpc/routers/workspaces/procedures/create.ts` around
lines 346 - 356, The return object for an existing workspace (where you spread
`existing` and set `branch`, `lastOpenedAt`) is missing the `initialCommands`
field, causing an inconsistent response shape versus the new-workspace branch
which sets `initialCommands: null`; update the existing-workspace return to
include `initialCommands: null` (so the returned object includes `workspace`,
`worktreePath`, `projectId`, `isInitializing`, `wasExisting`, and
`initialCommands`) to match the new-workspace branch and keep the API response
shape uniform.

}

const maxTabOrder = getMaxProjectChildTabOrder(input.projectId);
const workspace = localDb
.insert(workspaces)
.values({
projectId: input.projectId,
type: "branch",
branch,
name: input.name ?? branch,
tabOrder: maxTabOrder + 1,
})
.onConflictDoNothing()
.returning()
.all();

const ws = workspace[0] ?? getBranchWorkspace(input.projectId);
if (!ws) {
throw new Error("Failed to create or find branch workspace");
}

setLastActiveWorkspace(ws.id);
activateProject(project);

track("workspace_created", {
workspace_id: ws.id,
project_id: project.id,
type: "branch",
});

return {
workspace: ws,
initialCommands: null,
worktreePath: project.mainRepoPath,
projectId: project.id,
isInitializing: false,
wasExisting: false,
};
}

let existingBranchName: string | undefined;
if (input.useExistingBranch) {
existingBranchName = input.branchName?.trim();
Expand Down
30 changes: 23 additions & 7 deletions apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ export const createDeleteProcedures = () => {
id: z.string(),
deleteLocalBranch: z.boolean().optional(),
force: z.boolean().optional(),
trash: z.boolean().optional(),
}),
)
.mutation(async ({ input }) => {
Expand Down Expand Up @@ -252,13 +253,28 @@ export const createDeleteProcedures = () => {
await workspaceInitManager.acquireProjectLock(project.id);

try {
const removeResult = await removeWorktreeFromDisk({
mainRepoPath: project.mainRepoPath,
worktreePath: worktree.path,
});
if (!removeResult.success) {
clearWorkspaceDeletingStatus(input.id);
return removeResult;
if (input.trash) {
// Move to Trash (recoverable) instead of permanent delete
const { existsSync } = await import("node:fs");
if (existsSync(worktree.path)) {
const { shell } = await import("electron");
await shell.trashItem(worktree.path);
}
// Clean up stale git worktree references
const { getSimpleGitWithShellPath } = await import(
"../utils/git-client"
);
const git = await getSimpleGitWithShellPath(project.mainRepoPath);
await git.raw(["worktree", "prune"]);
} else {
const removeResult = await removeWorktreeFromDisk({
mainRepoPath: project.mainRepoPath,
worktreePath: worktree.path,
});
if (!removeResult.success) {
clearWorkspaceDeletingStatus(input.id);
return removeResult;
}
}
Comment on lines +256 to 278
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing error handling for trash operation may leave workspace in "deleting" state.

The trash path (lines 256-268) doesn't handle errors from shell.trashItem or git.raw(["worktree", "prune"]). If either fails, the exception propagates but clearWorkspaceDeletingStatus(input.id) is never called, leaving the workspace permanently marked as deleting.

Compare to the non-trash path (lines 270-277) which properly calls clearWorkspaceDeletingStatus on failure before returning.

🔧 Proposed fix to add error handling
 if (input.trash) {
 	// Move to Trash (recoverable) instead of permanent delete
-	const { existsSync } = await import("node:fs");
-	if (existsSync(worktree.path)) {
-		const { shell } = await import("electron");
-		await shell.trashItem(worktree.path);
+	try {
+		const { existsSync } = await import("node:fs");
+		if (existsSync(worktree.path)) {
+			const { shell } = await import("electron");
+			await shell.trashItem(worktree.path);
+		}
+		// Clean up stale git worktree references
+		const { getSimpleGitWithShellPath } = await import(
+			"../utils/git-client"
+		);
+		const git = await getSimpleGitWithShellPath(project.mainRepoPath);
+		await git.raw(["worktree", "prune"]);
+	} catch (error) {
+		clearWorkspaceDeletingStatus(input.id);
+		return {
+			success: false,
+			error: `Failed to trash worktree: ${error instanceof Error ? error.message : String(error)}`,
+		};
 	}
-	// Clean up stale git worktree references
-	const { getSimpleGitWithShellPath } = await import(
-		"../utils/git-client"
-	);
-	const git = await getSimpleGitWithShellPath(project.mainRepoPath);
-	await git.raw(["worktree", "prune"]);
 } else {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@apps/desktop/src/lib/trpc/routers/workspaces/procedures/delete.ts` around
lines 256 - 278, The trash branch lacks error handling: wrap the block that
calls shell.trashItem(worktree.path) and git.raw(["worktree", "prune"])
(obtained via getSimpleGitWithShellPath(project.mainRepoPath)) in a try/catch
and on any error call clearWorkspaceDeletingStatus(input.id) and return a
failure result consistent with removeWorktreeFromDisk's shape (so the caller can
handle the failure), ensuring worktree.path and project.mainRepoPath remain
referenced and no exception escapes the procedure.

} finally {
workspaceInitManager.releaseProjectLock(project.id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ export const createQueryProcedures = () => {
mainRepoPath: string;
hideImage: boolean;
iconUrl: string | null;
worktreeMode: string | null;
};
workspaces: WorkspaceItem[];
sections: SectionItem[];
Expand Down Expand Up @@ -185,6 +186,7 @@ export const createQueryProcedures = () => {
mainRepoPath: project.mainRepoPath,
hideImage: project.hideImage ?? false,
iconUrl: project.iconUrl ?? null,
worktreeMode: project.worktreeMode ?? null,
},
workspaces: [],
sections: projectSections,
Expand Down
Loading