-
Notifications
You must be signed in to change notification settings - Fork 990
feat(desktop): open project in dedicated window #2542
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
base: main
Are you sure you want to change the base?
Changes from all commits
e7a3779
1cf4a0f
6867bd2
60c9f87
5c8d783
f878bcd
797c226
6f86de9
8dfa3ce
5b9a80f
3ecf401
93a5305
d30adb6
c9f3bcb
44c055f
433d3d7
85d7416
1d49c37
028c388
cb8f280
dfd7cea
7bd787a
534c2f6
b758f1f
f68c9d7
5b6ee05
cf171a9
b4d6476
152f22f
c328fca
68c6196
53b5f62
2784f60
48da360
869fd78
6a594a2
5f9c120
1fef62b
b4e3cf4
96d15b2
e4a6a30
f9a6c6c
257a1ba
39566e4
2417163
4f5d3e9
47a4d7b
b824b4b
954595f
b5bf372
d34f945
746f8b3
c4a0324
4d6c419
9409aae
9785543
ba1c13f
3e531b8
cdd1ff4
41184a4
cae5110
7b18481
664570c
03d7fab
d2a7f5e
d14d19d
16f2b70
798c166
bf62698
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,20 +7,24 @@ import { | |
| projects, | ||
| type SelectProject, | ||
| settings, | ||
| WORKTREE_MODES, | ||
| workspaceSections, | ||
| workspaces, | ||
| worktrees, | ||
| } from "@superset/local-db"; | ||
| import { TRPCError } from "@trpc/server"; | ||
| import { observable } from "@trpc/server/observable"; | ||
| import { and, desc, eq, inArray, isNotNull, isNull, not } from "drizzle-orm"; | ||
| import type { BrowserWindow } from "electron"; | ||
| import { dialog } from "electron"; | ||
| import { track } from "main/lib/analytics"; | ||
| import { dataEmitter } from "main/lib/data-events"; | ||
| import { localDb } from "main/lib/local-db"; | ||
| import { | ||
| deleteProjectIcon, | ||
| saveProjectIconFromDataUrl, | ||
| } from "main/lib/project-icons"; | ||
| import { windowManager } from "main/lib/window-manager"; | ||
| import { getWorkspaceRuntimeRegistry } from "main/lib/workspace-runtime"; | ||
| import { PROJECT_COLOR_VALUES } from "shared/constants/project-colors"; | ||
| import { z } from "zod"; | ||
|
|
@@ -116,6 +120,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 }; | ||
| } | ||
|
|
||
|
|
@@ -130,6 +155,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; | ||
| } | ||
|
|
||
|
|
@@ -1230,6 +1273,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(), | ||
| }), | ||
| }), | ||
| ) | ||
|
|
@@ -1268,11 +1312,19 @@ 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)) | ||
| .run(); | ||
|
|
||
| dataEmitter.emit("projectChanged", { | ||
| projectId: input.id, | ||
| updatedAt: new Date().toISOString(), | ||
| }); | ||
|
|
||
| return { success: true }; | ||
| }), | ||
|
|
||
|
|
@@ -1366,7 +1418,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() | ||
|
|
@@ -1394,6 +1451,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"); | ||
|
Comment on lines
+1454
to
+1457
Contributor
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. Remove redundant dynamic import of
🔧 Proposed fix // Optionally move worktree directories to Trash
if (input.deleteWorktrees) {
- const { existsSync } = await import("node:fs");
const { shell } = await import("electron");🤖 Prompt for AI Agents |
||
|
|
||
| // 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) | ||
|
|
@@ -1431,6 +1538,12 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { | |
|
|
||
| track("project_closed", { project_id: input.id }); | ||
|
|
||
| // Close the project's dedicated window if one is open | ||
| const projectWin = windowManager.getProjectWindow(input.id); | ||
| if (projectWin && !projectWin.isDestroyed()) { | ||
| projectWin.close(); | ||
| } | ||
|
|
||
| return { success: true, terminalWarning }; | ||
| }), | ||
|
|
||
|
|
@@ -1602,6 +1715,18 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { | |
|
|
||
| return { iconUrl }; | ||
| }), | ||
|
|
||
| onProjectChanged: publicProcedure.subscription(() => { | ||
| return observable<{ projectId: string; updatedAt: string }>((emit) => { | ||
| const onChange = (data: { projectId: string; updatedAt: string }) => { | ||
| emit.next(data); | ||
| }; | ||
| dataEmitter.on("projectChanged", onChange); | ||
| return () => { | ||
| dataEmitter.off("projectChanged", onChange); | ||
| }; | ||
| }); | ||
| }), | ||
| }); | ||
| }; | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,8 @@ import { homedir } from "node:os"; | |
| import path from "node:path"; | ||
| import type { BrowserWindow } from "electron"; | ||
| import { dialog } from "electron"; | ||
| import { windowManager } from "main/lib/window-manager"; | ||
| import { ProjectWindow } from "main/windows/project"; | ||
| import { z } from "zod"; | ||
| import { publicProcedure, router } from ".."; | ||
|
|
||
|
|
@@ -126,6 +128,18 @@ export const createWindowRouter = (getWindow: () => BrowserWindow | null) => { | |
|
|
||
| return { canceled: false, dataUrl }; | ||
| }), | ||
|
|
||
| openProjectInNewWindow: publicProcedure | ||
| .input(z.object({ projectId: z.string() })) | ||
| .mutation(async ({ input }) => { | ||
| // If already open, just focus the existing window | ||
| if (windowManager.focusProjectWindow(input.projectId)) { | ||
| return { focused: true, opened: false }; | ||
| } | ||
| // Create a new project-focused window | ||
| await ProjectWindow(input.projectId); | ||
| return { focused: false, opened: true }; | ||
| }), | ||
|
Comment on lines
+132
to
+142
Contributor
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. Make open/focus dedup atomic for concurrent calls. Line 136 checks existing state, then Line 140 awaits creation. Two near-simultaneous calls for the same 🛠️ Proposed in-flight dedupe guard+const openingProjectWindows = new Map<string, Promise<void>>();
+
export const createWindowRouter = (getWindow: () => BrowserWindow | null) => {
return router({
...
openProjectInNewWindow: publicProcedure
.input(z.object({ projectId: z.string() }))
.mutation(async ({ input }) => {
// If already open, just focus the existing window
if (windowManager.focusProjectWindow(input.projectId)) {
return { focused: true, opened: false };
}
+
+ const existingOpen = openingProjectWindows.get(input.projectId);
+ if (existingOpen) {
+ await existingOpen;
+ return {
+ focused: windowManager.focusProjectWindow(input.projectId),
+ opened: false,
+ };
+ }
+
// Create a new project-focused window
- await ProjectWindow(input.projectId);
+ const openPromise = ProjectWindow(input.projectId)
+ .then(() => undefined)
+ .finally(() => openingProjectWindows.delete(input.projectId));
+ openingProjectWindows.set(input.projectId, openPromise);
+ await openPromise;
return { focused: false, opened: true };
}),🤖 Prompt for AI Agents |
||
| }); | ||
| }; | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
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.
P2: Cross-window project sync is incomplete:
projectChangedis emitted only fromupdate, while other project-mutating procedures do not emit, causing stale data in other windows.Prompt for AI agents