diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index eca6fc1919a..fda8582ecbb 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -3,6 +3,7 @@ import { router } from ".."; import { createExternalRouter } from "./external"; import { createNotificationsRouter } from "./notifications"; import { createProjectsRouter } from "./projects"; +import { createTabsRouter } from "./tabs"; import { createTerminalRouter } from "./terminal"; import { createWindowRouter } from "./window"; import { createWorkspacesRouter } from "./workspaces"; @@ -16,6 +17,7 @@ export const createAppRouter = (window: BrowserWindow) => { window: createWindowRouter(window), projects: createProjectsRouter(window), workspaces: createWorkspacesRouter(), + tabs: createTabsRouter(), terminal: createTerminalRouter(), notifications: createNotificationsRouter(), external: createExternalRouter(), diff --git a/apps/desktop/src/lib/trpc/routers/notifications/index.ts b/apps/desktop/src/lib/trpc/routers/notifications/index.ts new file mode 100644 index 00000000000..518ae656510 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/notifications/index.ts @@ -0,0 +1,2 @@ +export type { NotificationsRouter } from "./notifications"; +export { createNotificationsRouter } from "./notifications"; diff --git a/apps/desktop/src/lib/trpc/routers/notifications.ts b/apps/desktop/src/lib/trpc/routers/notifications/notifications.ts similarity index 93% rename from apps/desktop/src/lib/trpc/routers/notifications.ts rename to apps/desktop/src/lib/trpc/routers/notifications/notifications.ts index 0498013729b..56a7a870fb9 100644 --- a/apps/desktop/src/lib/trpc/routers/notifications.ts +++ b/apps/desktop/src/lib/trpc/routers/notifications/notifications.ts @@ -35,3 +35,5 @@ export const createNotificationsRouter = () => { }), }); }; + +export type NotificationsRouter = ReturnType; diff --git a/apps/desktop/src/lib/trpc/routers/projects/projects.ts b/apps/desktop/src/lib/trpc/routers/projects/projects.ts index 6c0d155e950..968275e76e3 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/projects.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts @@ -8,6 +8,7 @@ import { z } from "zod"; import { publicProcedure, router } from "../.."; import { getGitRoot } from "../workspaces/utils/git"; import { assignRandomColor } from "./utils/colors"; +import { getAllWithWorkspaces } from "./utils"; export const createProjectsRouter = (window: BrowserWindow) => { return router({ @@ -111,7 +112,13 @@ export const createProjectsRouter = (window: BrowserWindow) => { return { success: true }; }), + + getAllWithWorkspaces: publicProcedure.query(() => { + return getAllWithWorkspaces(db.data.projects, db.data.workspaces); + }), }); }; export type ProjectsRouter = ReturnType; + +export type ProjectWithWorkspaces = ReturnType[number]; diff --git a/apps/desktop/src/lib/trpc/routers/projects/utils/index.ts b/apps/desktop/src/lib/trpc/routers/projects/utils/index.ts new file mode 100644 index 00000000000..178cd64f81d --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/projects/utils/index.ts @@ -0,0 +1 @@ +export * from "./utils"; diff --git a/apps/desktop/src/lib/trpc/routers/projects/utils/utils.ts b/apps/desktop/src/lib/trpc/routers/projects/utils/utils.ts new file mode 100644 index 00000000000..183a27cf3f4 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/projects/utils/utils.ts @@ -0,0 +1,24 @@ +import type { Project, Workspace } from "main/lib/db/schemas"; + +/** + * Returns projects with their workspaces, ordered by project.tabOrder then workspace.tabOrder + */ +export function getAllWithWorkspaces( + allProjects: Project[], + allWorkspaces: Workspace[], +) { + const activeProjects = allProjects + .filter((p) => p.tabOrder !== null) + .sort((a, b) => a.tabOrder! - b.tabOrder!); + + return activeProjects.map((project) => { + const projectWorkspaces = allWorkspaces + .filter((w) => w.projectId === project.id) + .sort((a, b) => a.tabOrder - b.tabOrder); + + return { + ...project, + workspaces: projectWorkspaces, + }; + }); +} diff --git a/apps/desktop/src/lib/trpc/routers/tabs/index.ts b/apps/desktop/src/lib/trpc/routers/tabs/index.ts new file mode 100644 index 00000000000..b8f173f21d1 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/tabs/index.ts @@ -0,0 +1,2 @@ +export type { TabsRouter, Tab } from "./tabs"; +export { createTabsRouter } from "./tabs"; diff --git a/apps/desktop/src/lib/trpc/routers/tabs/tabs.test.ts b/apps/desktop/src/lib/trpc/routers/tabs/tabs.test.ts new file mode 100644 index 00000000000..0abd18e5610 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/tabs/tabs.test.ts @@ -0,0 +1,652 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test"; +import type { MosaicNode } from "react-mosaic-component"; +import { createTabsRouter } from "./tabs"; + +// Mock the database +const mockDb = { + data: { + workspaces: [ + { + id: "workspace-1", + projectId: "project-1", + worktreeId: "worktree-1", + name: "Test Workspace", + tabOrder: 0, + activeTabId: undefined as string | undefined, + isActive: true, + createdAt: Date.now(), + updatedAt: Date.now(), + lastOpenedAt: Date.now(), + }, + ], + tabs: [] as Array<{ + id: string; + workspaceId: string; + parentId?: string; + title: string; + type: "terminal" | "group"; + position: number; + layout?: MosaicNode | null; + needsAttention?: boolean; + createdAt: number; + updatedAt: number; + }>, + projects: [], + worktrees: [], + }, + update: mock(async (fn: (data: typeof mockDb.data) => void) => { + fn(mockDb.data); + }), +}; + +// Mock the database module +mock.module("main/lib/db", () => ({ + db: mockDb, +})); + +// Mock the terminal manager +const mockTerminalKill = mock(() => {}); +mock.module("main/lib/terminal-manager", () => ({ + terminalManager: { + kill: mockTerminalKill, + }, +})); + +// Reset mock data before each test +beforeEach(() => { + mockDb.data.workspaces = [ + { + id: "workspace-1", + projectId: "project-1", + worktreeId: "worktree-1", + name: "Test Workspace", + tabOrder: 0, + activeTabId: undefined, + isActive: true, + createdAt: Date.now(), + updatedAt: Date.now(), + lastOpenedAt: Date.now(), + }, + ]; + mockDb.data.tabs = []; + mockTerminalKill.mockClear(); +}); + +describe("tabs router - create", () => { + it("should create a terminal tab and set it as active", async () => { + const router = createTabsRouter(); + const caller = router.createCaller({}); + + const result = await caller.create({ + workspaceId: "workspace-1", + type: "terminal", + }); + + expect(result.type).toBe("terminal"); + expect(result.title).toBe("New Terminal"); + expect(result.workspaceId).toBe("workspace-1"); + expect(result.position).toBe(0); + expect(mockDb.data.tabs).toHaveLength(1); + expect(mockDb.data.workspaces[0].activeTabId).toBe(result.id); + }); + + it("should create a group tab", async () => { + const router = createTabsRouter(); + const caller = router.createCaller({}); + + const result = await caller.create({ + workspaceId: "workspace-1", + type: "group", + }); + + expect(result.type).toBe("group"); + expect(result.title).toBe("New Split View"); + }); + + it("should assign correct position for multiple tabs", async () => { + const router = createTabsRouter(); + const caller = router.createCaller({}); + + const tab1 = await caller.create({ + workspaceId: "workspace-1", + type: "terminal", + }); + const tab2 = await caller.create({ + workspaceId: "workspace-1", + type: "terminal", + }); + + expect(tab1.position).toBe(0); + expect(tab2.position).toBe(1); + }); +}); + +describe("tabs router - remove", () => { + it("should remove a terminal tab", async () => { + const router = createTabsRouter(); + const caller = router.createCaller({}); + + const tab = await caller.create({ + workspaceId: "workspace-1", + type: "terminal", + }); + + const result = await caller.remove({ id: tab.id }); + + expect(result.success).toBe(true); + expect(mockDb.data.tabs).toHaveLength(0); + expect(mockTerminalKill).toHaveBeenCalledWith({ tabId: tab.id }); + }); + + it("should remove a group and all its children", async () => { + const router = createTabsRouter(); + const caller = router.createCaller({}); + + // Create a group with children + const group = await caller.create({ + workspaceId: "workspace-1", + type: "group", + }); + const child1 = await caller.addChildTab({ + groupId: group.id, + type: "terminal", + }); + const child2 = await caller.addChildTab({ + groupId: group.id, + type: "terminal", + }); + + const result = await caller.remove({ id: group.id }); + + expect(result.success).toBe(true); + expect(mockDb.data.tabs).toHaveLength(0); + expect(mockTerminalKill).toHaveBeenCalledTimes(2); + expect(mockTerminalKill).toHaveBeenCalledWith({ tabId: child1.id }); + expect(mockTerminalKill).toHaveBeenCalledWith({ tabId: child2.id }); + }); + + it("should update activeTabId when removing active tab", async () => { + const router = createTabsRouter(); + const caller = router.createCaller({}); + + const tab1 = await caller.create({ + workspaceId: "workspace-1", + type: "terminal", + }); + const tab2 = await caller.create({ + workspaceId: "workspace-1", + type: "terminal", + }); + + // Set tab2 as active + await caller.setActive({ tabId: tab2.id }); + expect(mockDb.data.workspaces[0].activeTabId).toBe(tab2.id); + + // Remove tab2 + await caller.remove({ id: tab2.id }); + + // Should fall back to tab1 + expect(mockDb.data.workspaces[0].activeTabId).toBe(tab1.id); + }); + + it("should update activeTabId when removing group with active child", async () => { + const router = createTabsRouter(); + const caller = router.createCaller({}); + + // Create another top-level tab first + const regularTab = await caller.create({ + workspaceId: "workspace-1", + type: "terminal", + }); + + // Create a group with children + const group = await caller.create({ + workspaceId: "workspace-1", + type: "group", + }); + const child = await caller.addChildTab({ + groupId: group.id, + type: "terminal", + }); + + // Set child as active + await caller.setActive({ tabId: child.id }); + expect(mockDb.data.workspaces[0].activeTabId).toBe(child.id); + + // Remove the group (which includes the active child) + await caller.remove({ id: group.id }); + + // Should fall back to regularTab + expect(mockDb.data.workspaces[0].activeTabId).toBe(regularTab.id); + }); + + it("should delete empty parent when removing last child", async () => { + const router = createTabsRouter(); + const caller = router.createCaller({}); + + const group = await caller.create({ + workspaceId: "workspace-1", + type: "group", + }); + const child = await caller.addChildTab({ + groupId: group.id, + type: "terminal", + }); + + // Set up layout + await caller.updateLayout({ + groupId: group.id, + layout: child.id, + }); + + // Remove the child + await caller.remove({ id: child.id }); + + // Both child and empty parent should be deleted + expect(mockDb.data.tabs).toHaveLength(0); + }); + + it("should update activeTabId when removing child causes parent deletion", async () => { + const router = createTabsRouter(); + const caller = router.createCaller({}); + + // Create a regular tab first + const regularTab = await caller.create({ + workspaceId: "workspace-1", + type: "terminal", + }); + + // Create a group with one child + const group = await caller.create({ + workspaceId: "workspace-1", + type: "group", + }); + const child = await caller.addChildTab({ + groupId: group.id, + type: "terminal", + }); + + // Set up layout + await caller.updateLayout({ + groupId: group.id, + layout: child.id, + }); + + // Set child as active + await caller.setActive({ tabId: child.id }); + expect(mockDb.data.workspaces[0].activeTabId).toBe(child.id); + + // Remove the child (which will also delete the empty parent) + await caller.remove({ id: child.id }); + + // Should fall back to regularTab + expect(mockDb.data.workspaces[0].activeTabId).toBe(regularTab.id); + }); +}); + +describe("tabs router - update", () => { + it("should update tab title", async () => { + const router = createTabsRouter(); + const caller = router.createCaller({}); + + const tab = await caller.create({ + workspaceId: "workspace-1", + type: "terminal", + }); + + await caller.update({ + id: tab.id, + patch: { title: "Updated Title" }, + }); + + const updated = mockDb.data.tabs.find((t) => t.id === tab.id); + expect(updated?.title).toBe("Updated Title"); + }); + + it("should update needsAttention flag", async () => { + const router = createTabsRouter(); + const caller = router.createCaller({}); + + const tab = await caller.create({ + workspaceId: "workspace-1", + type: "terminal", + }); + + await caller.update({ + id: tab.id, + patch: { needsAttention: true }, + }); + + const updated = mockDb.data.tabs.find((t) => t.id === tab.id); + expect(updated?.needsAttention).toBe(true); + }); +}); + +describe("tabs router - setActive", () => { + it("should set tab as active and activate workspace", async () => { + const router = createTabsRouter(); + const caller = router.createCaller({}); + + const tab = await caller.create({ + workspaceId: "workspace-1", + type: "terminal", + }); + + await caller.setActive({ tabId: tab.id }); + + expect(mockDb.data.workspaces[0].activeTabId).toBe(tab.id); + expect(mockDb.data.workspaces[0].isActive).toBe(true); + }); + + it("should clear needsAttention flag when setting active", async () => { + const router = createTabsRouter(); + const caller = router.createCaller({}); + + const tab = await caller.create({ + workspaceId: "workspace-1", + type: "terminal", + }); + + await caller.update({ + id: tab.id, + patch: { needsAttention: true }, + }); + + await caller.setActive({ tabId: tab.id }); + + const updated = mockDb.data.tabs.find((t) => t.id === tab.id); + expect(updated?.needsAttention).toBe(false); + }); +}); + +describe("tabs router - reorder", () => { + it("should reorder tabs correctly", async () => { + const router = createTabsRouter(); + const caller = router.createCaller({}); + + const tab1 = await caller.create({ + workspaceId: "workspace-1", + type: "terminal", + }); + const tab2 = await caller.create({ + workspaceId: "workspace-1", + type: "terminal", + }); + const tab3 = await caller.create({ + workspaceId: "workspace-1", + type: "terminal", + }); + + // Move tab3 to position 0 + await caller.reorder({ tabId: tab3.id, targetIndex: 0 }); + + const tabs = mockDb.data.tabs + .filter((t) => !t.parentId) + .sort((a, b) => a.position - b.position); + + expect(tabs[0].id).toBe(tab3.id); + expect(tabs[1].id).toBe(tab1.id); + expect(tabs[2].id).toBe(tab2.id); + }); +}); + +describe("tabs router - updateLayout", () => { + it("should update group layout", async () => { + const router = createTabsRouter(); + const caller = router.createCaller({}); + + const group = await caller.create({ + workspaceId: "workspace-1", + type: "group", + }); + const child1 = await caller.addChildTab({ + groupId: group.id, + type: "terminal", + }); + const child2 = await caller.addChildTab({ + groupId: group.id, + type: "terminal", + }); + + const layout: MosaicNode = { + direction: "row", + first: child1.id, + second: child2.id, + }; + + await caller.updateLayout({ groupId: group.id, layout }); + + const updated = mockDb.data.tabs.find((t) => t.id === group.id); + expect(updated?.layout).toEqual(layout); + }); + + it("should delete removed tabs when layout changes", async () => { + const router = createTabsRouter(); + const caller = router.createCaller({}); + + const group = await caller.create({ + workspaceId: "workspace-1", + type: "group", + }); + const child1 = await caller.addChildTab({ + groupId: group.id, + type: "terminal", + }); + const child2 = await caller.addChildTab({ + groupId: group.id, + type: "terminal", + }); + + // Set layout with both children + await caller.updateLayout({ + groupId: group.id, + layout: { + direction: "row", + first: child1.id, + second: child2.id, + }, + }); + + mockTerminalKill.mockClear(); + + // Update layout to only include child1 + await caller.updateLayout({ + groupId: group.id, + layout: child1.id, + }); + + expect(mockDb.data.tabs.find((t) => t.id === child2.id)).toBeUndefined(); + expect(mockTerminalKill).toHaveBeenCalledWith({ tabId: child2.id }); + }); + + it("should delete group when layout is null", async () => { + const router = createTabsRouter(); + const caller = router.createCaller({}); + + const group = await caller.create({ + workspaceId: "workspace-1", + type: "group", + }); + + await caller.updateLayout({ groupId: group.id, layout: null }); + + expect(mockDb.data.tabs.find((t) => t.id === group.id)).toBeUndefined(); + }); + + it("should update activeTabId when removing child via layout change", async () => { + const router = createTabsRouter(); + const caller = router.createCaller({}); + + // Create a regular tab first + const regularTab = await caller.create({ + workspaceId: "workspace-1", + type: "terminal", + }); + + const group = await caller.create({ + workspaceId: "workspace-1", + type: "group", + }); + const child1 = await caller.addChildTab({ + groupId: group.id, + type: "terminal", + }); + const child2 = await caller.addChildTab({ + groupId: group.id, + type: "terminal", + }); + + // Set layout with both children + await caller.updateLayout({ + groupId: group.id, + layout: { + direction: "row", + first: child1.id, + second: child2.id, + }, + }); + + // Set child2 as active + await caller.setActive({ tabId: child2.id }); + expect(mockDb.data.workspaces[0].activeTabId).toBe(child2.id); + + // Update layout to remove child2 + await caller.updateLayout({ + groupId: group.id, + layout: child1.id, + }); + + // Should fall back to regularTab + expect(mockDb.data.workspaces[0].activeTabId).toBe(regularTab.id); + }); + + it("should update activeTabId when deleting group via null layout", async () => { + const router = createTabsRouter(); + const caller = router.createCaller({}); + + // Create a regular tab first + const regularTab = await caller.create({ + workspaceId: "workspace-1", + type: "terminal", + }); + + const group = await caller.create({ + workspaceId: "workspace-1", + type: "group", + }); + + // Set group as active + await caller.setActive({ tabId: group.id }); + expect(mockDb.data.workspaces[0].activeTabId).toBe(group.id); + + // Delete group via null layout + await caller.updateLayout({ groupId: group.id, layout: null }); + + // Should fall back to regularTab + expect(mockDb.data.workspaces[0].activeTabId).toBe(regularTab.id); + }); +}); + +describe("tabs router - addChildTab", () => { + it("should add child tab to group", async () => { + const router = createTabsRouter(); + const caller = router.createCaller({}); + + const group = await caller.create({ + workspaceId: "workspace-1", + type: "group", + }); + + const child = await caller.addChildTab({ + groupId: group.id, + type: "terminal", + }); + + expect(child.parentId).toBe(group.id); + expect(child.workspaceId).toBe("workspace-1"); + expect(mockDb.data.tabs).toHaveLength(2); + }); +}); + +describe("tabs router - ungroup", () => { + it("should ungroup children and delete group", async () => { + const router = createTabsRouter(); + const caller = router.createCaller({}); + + const group = await caller.create({ + workspaceId: "workspace-1", + type: "group", + }); + const child1 = await caller.addChildTab({ + groupId: group.id, + type: "terminal", + }); + const child2 = await caller.addChildTab({ + groupId: group.id, + type: "terminal", + }); + + await caller.ungroup({ groupId: group.id }); + + // Children should no longer have parentId + const updated1 = mockDb.data.tabs.find((t) => t.id === child1.id); + const updated2 = mockDb.data.tabs.find((t) => t.id === child2.id); + + expect(updated1?.parentId).toBeUndefined(); + expect(updated2?.parentId).toBeUndefined(); + + // Group should be deleted + expect(mockDb.data.tabs.find((t) => t.id === group.id)).toBeUndefined(); + + // Should have correct positions + expect(updated1?.position).toBe(0); + expect(updated2?.position).toBe(1); + }); +}); + +describe("tabs router - queries", () => { + it("getByWorkspace should return tabs sorted by position", async () => { + const router = createTabsRouter(); + const caller = router.createCaller({}); + + const tab1 = await caller.create({ + workspaceId: "workspace-1", + type: "terminal", + }); + const tab2 = await caller.create({ + workspaceId: "workspace-1", + type: "terminal", + }); + + const result = await caller.getByWorkspace({ workspaceId: "workspace-1" }); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe(tab1.id); + expect(result[1].id).toBe(tab2.id); + }); + + it("getActive should return active tab", async () => { + const router = createTabsRouter(); + const caller = router.createCaller({}); + + const tab = await caller.create({ + workspaceId: "workspace-1", + type: "terminal", + }); + + const result = await caller.getActive({ workspaceId: "workspace-1" }); + + expect(result?.id).toBe(tab.id); + }); + + it("getActive should return null when no active tab", async () => { + const router = createTabsRouter(); + const caller = router.createCaller({}); + + mockDb.data.workspaces[0].activeTabId = undefined; + + const result = await caller.getActive({ workspaceId: "workspace-1" }); + + expect(result).toBeNull(); + }); +}); diff --git a/apps/desktop/src/lib/trpc/routers/tabs/tabs.ts b/apps/desktop/src/lib/trpc/routers/tabs/tabs.ts new file mode 100644 index 00000000000..1dacc3a833d --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/tabs/tabs.ts @@ -0,0 +1,670 @@ +import type { MosaicNode } from "react-mosaic-component"; +import { updateTree } from "react-mosaic-component"; +import { db } from "main/lib/db"; +import type { Tab } from "main/lib/db/schemas"; +import { terminalManager } from "main/lib/terminal-manager"; +import { nanoid } from "nanoid"; +import { z } from "zod"; +import { publicProcedure, router } from "../.."; +import { extractTabIdsFromLayout, removeTabFromLayout } from "./utils/layout"; + +export const createTabsRouter = () => { + return router({ + // Queries + getByWorkspace: publicProcedure + .input(z.object({ workspaceId: z.string() })) + .query(({ input }) => { + return db.data.tabs + .filter((t) => t.workspaceId === input.workspaceId) + .sort((a, b) => a.position - b.position); + }), + + getActive: publicProcedure + .input(z.object({ workspaceId: z.string() })) + .query(({ input }) => { + const workspace = db.data.workspaces.find( + (w) => w.id === input.workspaceId, + ); + if (!workspace?.activeTabId) { + return null; + } + return db.data.tabs.find((t) => t.id === workspace.activeTabId) || null; + }), + + // Core Mutations + create: publicProcedure + .input( + z.object({ + workspaceId: z.string(), + type: z.enum(["terminal", "group"]), + }), + ) + .mutation(async ({ input }) => { + const workspaceTabs = db.data.tabs.filter( + (t) => t.workspaceId === input.workspaceId && !t.parentId, + ); + const maxPosition = + workspaceTabs.length > 0 + ? Math.max(...workspaceTabs.map((t) => t.position)) + : -1; + + const newTab: Tab = { + id: nanoid(), + workspaceId: input.workspaceId, + title: input.type === "terminal" ? "New Terminal" : "New Split View", + type: input.type, + position: maxPosition + 1, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + await db.update((data) => { + data.tabs.push(newTab); + + // Set as active tab + const workspace = data.workspaces.find( + (w) => w.id === input.workspaceId, + ); + if (workspace) { + workspace.activeTabId = newTab.id; + } + }); + + return newTab; + }), + + remove: publicProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ input }) => { + const tab = db.data.tabs.find((t) => t.id === input.id); + if (!tab) { + return { success: false, error: "Tab not found" }; + } + + await db.update((data) => { + // Collect all removed tab IDs + const removedTabIds: string[] = []; + + // If tab has children (is a group), delete them too + if (tab.type === "group") { + const childIds = data.tabs + .filter((t) => t.parentId === tab.id) + .map((t) => t.id); + + removedTabIds.push(tab.id, ...childIds); + + data.tabs = data.tabs.filter( + (t) => t.id !== tab.id && !childIds.includes(t.id), + ); + + // Kill terminals for children + for (const childId of childIds) { + terminalManager.kill({ tabId: childId }); + } + } else { + // If tab is a child, remove from parent layout + if (tab.parentId) { + const parent = data.tabs.find((t) => t.id === tab.parentId); + if (parent && parent.type === "group" && parent.layout) { + parent.layout = removeTabFromLayout(parent.layout, tab.id); + + // If parent becomes empty, delete it + if (!parent.layout) { + removedTabIds.push(parent.id); + data.tabs = data.tabs.filter((t) => t.id !== parent.id); + } + } + } + + removedTabIds.push(tab.id); + data.tabs = data.tabs.filter((t) => t.id !== tab.id); + + // Kill terminal if terminal type + if (tab.type === "terminal") { + terminalManager.kill({ tabId: tab.id }); + } + } + + // Update active tab if needed + const workspace = data.workspaces.find( + (w) => w.id === tab.workspaceId, + ); + if ( + workspace?.activeTabId && + removedTabIds.includes(workspace.activeTabId) + ) { + const remainingTabs = data.tabs + .filter((t) => t.workspaceId === tab.workspaceId && !t.parentId) + .sort((a, b) => a.position - b.position); + workspace.activeTabId = remainingTabs[0]?.id; + } + }); + + return { success: true }; + }), + + update: publicProcedure + .input( + z.object({ + id: z.string(), + patch: z.object({ + title: z.string().optional(), + needsAttention: z.boolean().optional(), + }), + }), + ) + .mutation(async ({ input }) => { + await db.update((data) => { + const tab = data.tabs.find((t) => t.id === input.id); + if (!tab) { + throw new Error("Tab not found"); + } + + if (input.patch.title !== undefined) { + tab.title = input.patch.title; + } + if (input.patch.needsAttention !== undefined) { + tab.needsAttention = input.patch.needsAttention; + } + + tab.updatedAt = Date.now(); + }); + + return { success: true }; + }), + + setActive: publicProcedure + .input(z.object({ tabId: z.string() })) + .mutation(async ({ input }) => { + const tab = db.data.tabs.find((t) => t.id === input.tabId); + if (!tab) { + throw new Error("Tab not found"); + } + + await db.update((data) => { + // Deactivate all workspaces + for (const ws of data.workspaces) { + ws.isActive = false; + } + + // Activate workspace and set active tab + const workspace = data.workspaces.find( + (w) => w.id === tab.workspaceId, + ); + if (workspace) { + workspace.activeTabId = input.tabId; + workspace.isActive = true; + workspace.lastOpenedAt = Date.now(); + } + + // Clear needs attention flag + const t = data.tabs.find((t) => t.id === input.tabId); + if (t) { + t.needsAttention = false; + } + }); + + return { success: true }; + }), + + reorder: publicProcedure + .input( + z.object({ + tabId: z.string(), + targetIndex: z.number(), + }), + ) + .mutation(async ({ input }) => { + const tab = db.data.tabs.find((t) => t.id === input.tabId); + if (!tab) { + throw new Error("Tab not found"); + } + + await db.update((data) => { + const workspaceTabs = data.tabs + .filter((t) => t.workspaceId === tab.workspaceId && !t.parentId) + .sort((a, b) => a.position - b.position); + + const currentIndex = workspaceTabs.findIndex( + (t) => t.id === input.tabId, + ); + if (currentIndex === -1) return; + + // Reorder + const [moved] = workspaceTabs.splice(currentIndex, 1); + workspaceTabs.splice(input.targetIndex, 0, moved); + + // Update positions + for (let index = 0; index < workspaceTabs.length; index++) { + const t = workspaceTabs[index]; + const tabInDb = data.tabs.find((tab) => tab.id === t.id); + if (tabInDb) { + tabInDb.position = index; + } + } + }); + + return { success: true }; + }), + + // Group Operations + updateLayout: publicProcedure + .input( + z.object({ + groupId: z.string(), + layout: z.any(), // MosaicNode | null + }), + ) + .mutation(async ({ input }) => { + const group = db.data.tabs.find((t) => t.id === input.groupId); + if (!group || group.type !== "group") { + throw new Error("Group not found"); + } + + const oldTabIds = extractTabIdsFromLayout(group.layout || null); + const newTabIds = extractTabIdsFromLayout(input.layout); + const removedTabIds = Array.from(oldTabIds).filter( + (id) => !newTabIds.has(id), + ); + + await db.update((data) => { + const g = data.tabs.find((t) => t.id === input.groupId); + if (g && g.type === "group") { + g.layout = input.layout; + g.updatedAt = Date.now(); + + // If layout is null, delete the group + if (!input.layout) { + removedTabIds.push(input.groupId); + data.tabs = data.tabs.filter((t) => t.id !== input.groupId); + } + } + + // Delete removed tabs + data.tabs = data.tabs.filter((t) => !removedTabIds.includes(t.id)); + + // Update active tab if needed + const workspace = data.workspaces.find( + (w) => w.id === group.workspaceId, + ); + if ( + workspace?.activeTabId && + removedTabIds.includes(workspace.activeTabId) + ) { + const remainingTabs = data.tabs + .filter((t) => t.workspaceId === group.workspaceId && !t.parentId) + .sort((a, b) => a.position - b.position); + workspace.activeTabId = remainingTabs[0]?.id; + } + }); + + // Kill terminals for removed tabs + for (const tabId of removedTabIds) { + terminalManager.kill({ tabId }); + } + + return { success: true }; + }), + + addChildTab: publicProcedure + .input( + z.object({ + groupId: z.string(), + type: z.enum(["terminal"]), + }), + ) + .mutation(async ({ input }) => { + const group = db.data.tabs.find((t) => t.id === input.groupId); + if (!group || group.type !== "group") { + throw new Error("Group not found"); + } + + const newTab: Tab = { + id: nanoid(), + workspaceId: group.workspaceId, + parentId: input.groupId, + title: "New Terminal", + type: input.type, + position: 0, // Child tabs don't need position + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + await db.update((data) => { + data.tabs.push(newTab); + }); + + // Frontend will call updateLayout to add this tab to mosaic + return newTab; + }), + + ungroup: publicProcedure + .input(z.object({ groupId: z.string() })) + .mutation(async ({ input }) => { + const group = db.data.tabs.find((t) => t.id === input.groupId); + if (!group || group.type !== "group") { + throw new Error("Group not found"); + } + + await db.update((data) => { + const children = data.tabs.filter( + (t) => t.parentId === input.groupId, + ); + const workspaceTabs = data.tabs.filter( + (t) => t.workspaceId === group.workspaceId && !t.parentId, + ); + const groupIndex = workspaceTabs.findIndex( + (t) => t.id === input.groupId, + ); + + // Remove parentId from children and assign positions + for (const child of children) { + delete child.parentId; + child.position = groupIndex + children.indexOf(child); + } + + // Recompute positions for all workspace tabs + const allTabs = data.tabs + .filter((t) => t.workspaceId === group.workspaceId && !t.parentId) + .filter((t) => t.id !== input.groupId) + .sort((a, b) => a.position - b.position); + + for (let i = 0; i < allTabs.length; i++) { + allTabs[i].position = i; + } + + // Delete group + data.tabs = data.tabs.filter((t) => t.id !== input.groupId); + }); + + return { success: true }; + }), + + moveOutOfGroup: publicProcedure + .input( + z.object({ + tabId: z.string(), + targetIndex: z.number(), + }), + ) + .mutation(async ({ input }) => { + const tab = db.data.tabs.find((t) => t.id === input.tabId); + if (!tab || !tab.parentId) { + throw new Error("Tab not found or not in a group"); + } + + await db.update((data) => { + const t = data.tabs.find((t) => t.id === input.tabId); + if (!t || !t.parentId) return; + + // Remove from parent + delete t.parentId; + + // Reorder to target position + const workspaceTabs = data.tabs + .filter((t) => t.workspaceId === tab.workspaceId && !t.parentId) + .sort((a, b) => a.position - b.position); + + const currentIndex = workspaceTabs.findIndex( + (t) => t.id === input.tabId, + ); + const [moved] = workspaceTabs.splice(currentIndex, 1); + workspaceTabs.splice(input.targetIndex, 0, moved); + + // Update all positions + for (let i = 0; i < workspaceTabs.length; i++) { + const tab = data.tabs.find((t) => t.id === workspaceTabs[i].id); + if (tab) { + tab.position = i; + } + } + }); + + return { success: true }; + }), + + setParent: publicProcedure + .input( + z.object({ + tabId: z.string(), + parentId: z.string().nullable(), + }), + ) + .mutation(async ({ input }) => { + const tab = db.data.tabs.find((t) => t.id === input.tabId); + if (!tab) { + throw new Error("Tab not found"); + } + + if (input.parentId) { + const parent = db.data.tabs.find((t) => t.id === input.parentId); + if (!parent || parent.type !== "group") { + throw new Error("Parent must be a group tab"); + } + } + + await db.update((data) => { + const t = data.tabs.find((t) => t.id === input.tabId); + if (!t) return; + + if (input.parentId) { + t.parentId = input.parentId; + } else { + delete t.parentId; + } + + t.updatedAt = Date.now(); + }); + + return { success: true }; + }), + + splitActiveTab: publicProcedure + .input( + z.object({ + workspaceId: z.string(), + direction: z.enum(["row", "column"]), + }), + ) + .mutation(async ({ input }) => { + const workspace = db.data.workspaces.find( + (w) => w.id === input.workspaceId, + ); + if (!workspace?.activeTabId) { + throw new Error("No active tab in workspace"); + } + + const activeTab = db.data.tabs.find( + (t) => t.id === workspace.activeTabId, + ); + if (!activeTab) { + throw new Error("Active tab not found"); + } + + // Find the terminal to split + let terminalToSplit; + if (activeTab.type === "terminal") { + terminalToSplit = activeTab; + } else { + // Active tab is a group - find first child terminal + terminalToSplit = db.data.tabs.find( + (t) => t.parentId === activeTab.id && t.type === "terminal", + ); + } + + if (!terminalToSplit) { + throw new Error("No terminal found to split"); + } + + // Perform the split (inline logic from split procedure) + await db.update((data) => { + const t = data.tabs.find((t) => t.id === terminalToSplit!.id); + if (!t || t.type !== "terminal") return; + + // Only handle standalone terminal case (no path for splitActiveTab) + if (!t.parentId) { + const groupId = nanoid(); + const newChildId = nanoid(); + + // Create group tab + const groupTab: Tab = { + id: groupId, + workspaceId: t.workspaceId, + title: `${t.title} - Split`, + type: "group", + position: t.position, + layout: { + direction: input.direction, + first: t.id, + second: newChildId, + splitPercentage: 50, + }, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + // Create new child terminal + const newChildTab: Tab = { + id: newChildId, + workspaceId: t.workspaceId, + parentId: groupId, + title: "New Terminal", + type: "terminal", + position: 0, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + // Update original tab to be child of group + t.parentId = groupId; + + // Add tabs to data + data.tabs.push(groupTab, newChildTab); + + // Set group as active + const ws = data.workspaces.find( + (w) => w.id === t.workspaceId, + ); + if (ws) { + ws.activeTabId = groupId; + } + } + }); + + return { success: true }; + }), + + split: publicProcedure + .input( + z.object({ + tabId: z.string(), + direction: z.enum(["row", "column"]), + path: z.array(z.enum(["first", "second"])).optional(), + }), + ) + .mutation(async ({ input }) => { + const tab = db.data.tabs.find((t) => t.id === input.tabId); + if (!tab || tab.type !== "terminal") { + throw new Error("Can only split terminal tabs"); + } + + await db.update((data) => { + const t = data.tabs.find((t) => t.id === input.tabId); + if (!t || t.type !== "terminal") return; + + // Case 1: Splitting a standalone terminal → create group + if (!t.parentId) { + const groupId = nanoid(); + const newChildId = nanoid(); + + // Create group tab + const groupTab: Tab = { + id: groupId, + workspaceId: t.workspaceId, + title: `${t.title} - Split`, + type: "group", + position: t.position, + layout: { + direction: input.direction, + first: t.id, + second: newChildId, + splitPercentage: 50, + }, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + // Create new child terminal + const newChildTab: Tab = { + id: newChildId, + workspaceId: t.workspaceId, + parentId: groupId, + title: "New Terminal", + type: "terminal", + position: 0, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + // Update original tab to be child of group + t.parentId = groupId; + + // Add tabs to data + data.tabs.push(groupTab, newChildTab); + + // Set group as active + const workspace = data.workspaces.find( + (w) => w.id === t.workspaceId, + ); + if (workspace) { + workspace.activeTabId = groupId; + } + } + // Case 2: Splitting within a group → add child and update layout + else if (input.path) { + const parent = data.tabs.find((tab) => tab.id === t.parentId); + if (!parent || parent.type !== "group" || !parent.layout) return; + + const newChildId = nanoid(); + + // Create new child terminal + const newChildTab: Tab = { + id: newChildId, + workspaceId: t.workspaceId, + parentId: t.parentId, + title: "New Terminal", + type: "terminal", + position: 0, + createdAt: Date.now(), + updatedAt: Date.now(), + }; + + // Update layout using Mosaic's updateTree + parent.layout = updateTree(parent.layout, [ + { + path: input.path, + spec: { + $set: { + direction: input.direction, + first: t.id, + second: newChildId, + splitPercentage: 50, + }, + }, + }, + ]); + parent.updatedAt = Date.now(); + + data.tabs.push(newChildTab); + } + }); + + return { success: true }; + }), + }); +}; + +export type TabsRouter = ReturnType; + +// Export Tab type for use in components +export type Tab = TabsRouter["getByWorkspace"]["_def"]["_output_out"][number]; diff --git a/apps/desktop/src/lib/trpc/routers/tabs/utils/layout/index.ts b/apps/desktop/src/lib/trpc/routers/tabs/utils/layout/index.ts new file mode 100644 index 00000000000..eec9d1e67dd --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/tabs/utils/layout/index.ts @@ -0,0 +1 @@ +export * from "./layout"; diff --git a/apps/desktop/src/lib/trpc/routers/tabs/utils/layout/layout.ts b/apps/desktop/src/lib/trpc/routers/tabs/utils/layout/layout.ts new file mode 100644 index 00000000000..f404cc8d760 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/tabs/utils/layout/layout.ts @@ -0,0 +1,49 @@ +import type { MosaicNode } from "react-mosaic-component"; + +/** + * Extract all tab IDs from a mosaic layout tree + */ +export function extractTabIdsFromLayout( + layout: MosaicNode | null, +): Set { + const ids = new Set(); + if (!layout) return ids; + + if (typeof layout === "string") { + ids.add(layout); + } else { + const firstIds = extractTabIdsFromLayout(layout.first); + const secondIds = extractTabIdsFromLayout(layout.second); + for (const id of firstIds) ids.add(id); + for (const id of secondIds) ids.add(id); + } + + return ids; +} + +/** + * Remove a tab ID from a mosaic layout tree + */ +export function removeTabFromLayout( + layout: MosaicNode | null, + tabIdToRemove: string, +): MosaicNode | null { + if (!layout) return null; + + if (typeof layout === "string") { + return layout === tabIdToRemove ? null : layout; + } + + const newFirst = removeTabFromLayout(layout.first, tabIdToRemove); + const newSecond = removeTabFromLayout(layout.second, tabIdToRemove); + + if (!newFirst && !newSecond) return null; + if (!newFirst) return newSecond; + if (!newSecond) return newFirst; + + return { + ...layout, + first: newFirst, + second: newSecond, + }; +} diff --git a/apps/desktop/src/lib/trpc/routers/terminal/index.ts b/apps/desktop/src/lib/trpc/routers/terminal/index.ts index f8238dbfac3..60776010396 100644 --- a/apps/desktop/src/lib/trpc/routers/terminal/index.ts +++ b/apps/desktop/src/lib/trpc/routers/terminal/index.ts @@ -1 +1,2 @@ +export type { TerminalRouter } from "./terminal"; export { createTerminalRouter } from "./terminal"; diff --git a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts index 9d28c276a4e..548a4fa9d0f 100644 --- a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts +++ b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts @@ -177,3 +177,5 @@ export const createTerminalRouter = () => { }), }); }; + +export type TerminalRouter = ReturnType; diff --git a/apps/desktop/src/lib/trpc/routers/window/index.ts b/apps/desktop/src/lib/trpc/routers/window/index.ts new file mode 100644 index 00000000000..bc3a85863d0 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/window/index.ts @@ -0,0 +1,2 @@ +export type { WindowRouter } from "./window"; +export { createWindowRouter } from "./window"; diff --git a/apps/desktop/src/lib/trpc/routers/window.ts b/apps/desktop/src/lib/trpc/routers/window/window.ts similarity index 100% rename from apps/desktop/src/lib/trpc/routers/window.ts rename to apps/desktop/src/lib/trpc/routers/window/window.ts diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git/git.ts similarity index 100% rename from apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts rename to apps/desktop/src/lib/trpc/routers/workspaces/utils/git/git.ts diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/git/index.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git/index.ts new file mode 100644 index 00000000000..dca10acc38a --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/git/index.ts @@ -0,0 +1 @@ +export * from "./git"; diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/index.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/index.ts new file mode 100644 index 00000000000..178cd64f81d --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/index.ts @@ -0,0 +1 @@ +export * from "./utils"; diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/utils.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/utils.ts new file mode 100644 index 00000000000..cfdcd9c51cb --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/utils.ts @@ -0,0 +1,35 @@ +import type { Project, Workspace } from "main/lib/db/schemas"; +import { getAllWithWorkspaces } from "../../projects/utils"; + +/** + * Finds the adjacent workspace to activate after deleting a workspace. + * + * @param allProjects - All projects in the database + * @param allWorkspaces - All workspaces in the database + * @param deletedWorkspaceId - The ID of the workspace being deleted + * @returns The workspace that should become active, or undefined + */ +export function findAdjacentWorkspace( + allProjects: Project[], + allWorkspaces: Workspace[], + deletedWorkspaceId: string, +): Workspace | undefined { + const grouped = getAllWithWorkspaces(allProjects, allWorkspaces); + const orderedWorkspaces = grouped.flatMap((group) => group.workspaces); + + const deletedIndex = orderedWorkspaces.findIndex( + (w) => w.id === deletedWorkspaceId, + ); + + if (deletedIndex === -1 || orderedWorkspaces.length === 0) return undefined; + + if (deletedIndex > 0) { + return orderedWorkspaces[deletedIndex - 1]; + } + + if (deletedIndex < orderedWorkspaces.length - 1) { + return orderedWorkspaces[deletedIndex + 1]; + } + + return undefined; +} diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.test.ts b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.test.ts index 9c3c8fc54d2..7b9975322c4 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.test.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.test.ts @@ -11,6 +11,8 @@ const mockDb = { worktreeId: "worktree-1", name: "Test Workspace", tabOrder: 0, + activeTabId: undefined, + isActive: true, createdAt: Date.now(), updatedAt: Date.now(), lastOpenedAt: Date.now(), @@ -36,9 +38,7 @@ const mockDb = { lastOpenedAt: Date.now(), }, ], - settings: { - lastActiveWorkspaceId: "workspace-1", - }, + tabs: [], }, update: mock(async (fn: (data: typeof mockDb.data) => void) => { fn(mockDb.data); @@ -64,6 +64,13 @@ mock.module("./utils/git", () => ({ generateBranchName: mockGenerateBranchName, })); +// Mock the terminal manager +mock.module("main/lib/terminal-manager", () => ({ + terminalManager: { + kill: mock(() => {}), + }, +})); + // Reset mock data before each test beforeEach(() => { // Reset the removeWorktree mock to default success behavior @@ -78,6 +85,8 @@ beforeEach(() => { worktreeId: "worktree-1", name: "Test Workspace", tabOrder: 0, + activeTabId: undefined, + isActive: true, createdAt: Date.now(), updatedAt: Date.now(), lastOpenedAt: Date.now(), @@ -103,6 +112,7 @@ beforeEach(() => { lastOpenedAt: Date.now(), }, ]; + mockDb.data.tabs = []; mockDb.data.settings = { lastActiveWorkspaceId: "workspace-1", }; @@ -120,7 +130,7 @@ describe("workspaces router - delete", () => { expect(mockDb.data.worktrees).toHaveLength(0); }); - it("should fail deletion if worktree removal fails", async () => { + it("should still delete workspace from DB even if worktree removal fails", async () => { // Override the removeWorktree mock to fail for this test mockRemoveWorktree = mock((_mainRepoPath: string, _worktreePath: string) => Promise.reject(new Error("Failed to remove worktree")), @@ -131,222 +141,11 @@ describe("workspaces router - delete", () => { const result = await caller.delete({ id: "workspace-1" }); - expect(result.success).toBe(false); - expect(result.error).toContain("Failed to remove worktree"); - // Workspace should NOT be removed from DB if worktree removal fails - expect(mockDb.data.workspaces).toHaveLength(1); - expect(mockDb.data.worktrees).toHaveLength(1); - }); -}); - -describe("workspaces router - canDelete", () => { - it("should return true when worktree can be deleted", async () => { - // Mock git to return worktree list in porcelain format - const mockGit = { - raw: mock(() => - Promise.resolve( - "worktree /path/to/worktree\nHEAD abc123\nbranch refs/heads/test-branch\n\nworktree /path/to/other-worktree\nHEAD def456\nbranch refs/heads/other-branch", - ), - ), - }; - const mockSimpleGit = mock(() => mockGit); - mock.module("simple-git", () => ({ - default: mockSimpleGit, - })); - - const router = createWorkspacesRouter(); - const caller = router.createCaller({}); - - const result = await caller.canDelete({ id: "workspace-1" }); - - expect(result.canDelete).toBe(true); - expect(result.reason).toBeNull(); - expect(result.warning).toBeNull(); - }); - - it("should return warning when worktree doesn't exist in git", async () => { - // Mock git to return worktree list without our worktree (porcelain format) - const mockGit = { - raw: mock(() => - Promise.resolve( - "worktree /path/to/other-worktree\nHEAD def456\nbranch refs/heads/other-branch", - ), - ), - }; - const mockSimpleGit = mock(() => mockGit); - mock.module("simple-git", () => ({ - default: mockSimpleGit, - })); - - const router = createWorkspacesRouter(); - const caller = router.createCaller({}); - - const result = await caller.canDelete({ id: "workspace-1" }); - - expect(result.canDelete).toBe(true); - expect(result.warning).toContain("not found in git"); - }); - - it("should return false when git check fails", async () => { - // Mock git to throw error - const mockGit = { - raw: mock(() => Promise.reject(new Error("Git error"))), - }; - const mockSimpleGit = mock(() => mockGit); - mock.module("simple-git", () => ({ - default: mockSimpleGit, - })); - - const router = createWorkspacesRouter(); - const caller = router.createCaller({}); - - const result = await caller.canDelete({ id: "workspace-1" }); - - expect(result.canDelete).toBe(false); - expect(result.reason).toContain("Failed to check worktree status"); - }); - - it("should use exact path matching and not match substrings", async () => { - // Mock git to return a worktree with a similar but different path - // This tests that we don't match "/path/to/worktree-backup" when looking for "/path/to/worktree" - const mockGit = { - raw: mock(() => - Promise.resolve( - "worktree /path/to/worktree-backup\nHEAD abc123\nbranch refs/heads/backup\n\nworktree /path/to/worktree-old\nHEAD def456\nbranch refs/heads/old", - ), - ), - }; - const mockSimpleGit = mock(() => mockGit); - mock.module("simple-git", () => ({ - default: mockSimpleGit, - })); - - const router = createWorkspacesRouter(); - const caller = router.createCaller({}); - - const result = await caller.canDelete({ id: "workspace-1" }); - - // Should not find the worktree because neither path exactly matches "/path/to/worktree" - expect(result.canDelete).toBe(true); - expect(result.warning).toContain("not found in git"); - }); - - it("should match exact path even with trailing whitespace in git output", async () => { - // Mock git to return worktree list with trailing spaces - const mockGit = { - raw: mock(() => - Promise.resolve( - "worktree /path/to/worktree \nHEAD abc123\nbranch refs/heads/test-branch", - ), - ), - }; - const mockSimpleGit = mock(() => mockGit); - mock.module("simple-git", () => ({ - default: mockSimpleGit, - })); - - const router = createWorkspacesRouter(); - const caller = router.createCaller({}); - - const result = await caller.canDelete({ id: "workspace-1" }); - - // Should find the worktree even with trailing whitespace - expect(result.canDelete).toBe(true); - expect(result.reason).toBeNull(); - expect(result.warning).toBeNull(); - }); - - it("should handle worktree path that is a prefix of another path", async () => { - // Update mock DB to have a path that could be a prefix - mockDb.data.worktrees = [ - { - id: "worktree-1", - projectId: "project-1", - path: "/path/to/main", - branch: "test-branch", - createdAt: Date.now(), - }, - ]; - - // Mock git to return a list with a path that contains our path as prefix - const mockGit = { - raw: mock(() => - Promise.resolve( - "worktree /path/to/main-backup\nHEAD abc123\nbranch refs/heads/backup\n\nworktree /path/to/main2\nHEAD def456\nbranch refs/heads/other", - ), - ), - }; - const mockSimpleGit = mock(() => mockGit); - mock.module("simple-git", () => ({ - default: mockSimpleGit, - })); - - const router = createWorkspacesRouter(); - const caller = router.createCaller({}); - - const result = await caller.canDelete({ id: "workspace-1" }); - - // Should not find "/path/to/main" even though similar paths exist - expect(result.canDelete).toBe(true); - expect(result.warning).toContain("not found in git"); - }); - - it("should handle worktree path that contains another path", async () => { - // Update mock DB to have a longer path - mockDb.data.worktrees = [ - { - id: "worktree-1", - projectId: "project-1", - path: "/path/to/worktree-backup", - branch: "test-branch", - createdAt: Date.now(), - }, - ]; - - // Mock git to return a list with a shorter path - const mockGit = { - raw: mock(() => - Promise.resolve( - "worktree /path/to/worktree\nHEAD abc123\nbranch refs/heads/main", - ), - ), - }; - const mockSimpleGit = mock(() => mockGit); - mock.module("simple-git", () => ({ - default: mockSimpleGit, - })); - - const router = createWorkspacesRouter(); - const caller = router.createCaller({}); - - const result = await caller.canDelete({ id: "workspace-1" }); - - // Should not find "/path/to/worktree-backup" when only "/path/to/worktree" exists - expect(result.canDelete).toBe(true); - expect(result.warning).toContain("not found in git"); - }); - - it("should verify --porcelain flag is passed to git", async () => { - // Mock git to capture the arguments - const rawMock = mock(() => - Promise.resolve( - "worktree /path/to/worktree\nHEAD abc123\nbranch refs/heads/test-branch", - ), - ); - const mockGit = { - raw: rawMock, - }; - const mockSimpleGit = mock(() => mockGit); - mock.module("simple-git", () => ({ - default: mockSimpleGit, - })); - - const router = createWorkspacesRouter(); - const caller = router.createCaller({}); - - await caller.canDelete({ id: "workspace-1" }); - - // Verify that raw was called with the correct arguments - expect(rawMock).toHaveBeenCalledWith(["worktree", "list", "--porcelain"]); + // Should succeed with a warning + expect(result.success).toBe(true); + expect(result.warning).toContain("couldn't remove git worktree"); + // Workspace SHOULD be removed from DB even if worktree removal fails + expect(mockDb.data.workspaces).toHaveLength(0); + expect(mockDb.data.worktrees).toHaveLength(0); }); }); diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts index 4791448f464..b940eab815d 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts @@ -1,5 +1,6 @@ import { join } from "node:path"; import { db } from "main/lib/db"; +import { terminalManager } from "main/lib/terminal-manager"; import { nanoid } from "nanoid"; import simpleGit from "simple-git"; import { z } from "zod"; @@ -9,6 +10,7 @@ import { generateBranchName, removeWorktree, } from "./utils/git"; +import { findAdjacentWorkspace } from "./utils"; export const createWorkspacesRouter = () => { return router({ @@ -53,15 +55,20 @@ export const createWorkspacesRouter = () => { worktreeId: worktree.id, name: input.name ?? branch, tabOrder: maxTabOrder + 1, + activeTabId: undefined, + isActive: true, createdAt: Date.now(), updatedAt: Date.now(), lastOpenedAt: Date.now(), }; await db.update((data) => { + for (const ws of data.workspaces) { + ws.isActive = false; + } + data.worktrees.push(worktree); data.workspaces.push(workspace); - data.settings.lastActiveWorkspaceId = workspace.id; const p = data.projects.find((p) => p.id === input.projectId); if (p) { @@ -97,77 +104,9 @@ export const createWorkspacesRouter = () => { return db.data.workspaces.slice().sort((a, b) => a.tabOrder - b.tabOrder); }), - getAllGrouped: publicProcedure.query(() => { - const activeProjects = db.data.projects.filter( - (p) => p.tabOrder !== null, - ); - - const groupsMap = new Map< - string, - { - project: { - id: string; - name: string; - color: string; - tabOrder: number; - }; - workspaces: Array<{ - id: string; - projectId: string; - worktreeId: string; - name: string; - tabOrder: number; - createdAt: number; - updatedAt: number; - lastOpenedAt: number; - }>; - } - >(); - - for (const project of activeProjects) { - groupsMap.set(project.id, { - project: { - id: project.id, - name: project.name, - color: project.color, - tabOrder: project.tabOrder!, - }, - workspaces: [], - }); - } - - const workspaces = db.data.workspaces - .slice() - .sort((a, b) => a.tabOrder - b.tabOrder); - - for (const workspace of workspaces) { - if (groupsMap.has(workspace.projectId)) { - groupsMap.get(workspace.projectId)!.workspaces.push(workspace); - } - } - - return Array.from(groupsMap.values()).sort( - (a, b) => a.project.tabOrder - b.project.tabOrder, - ); - }), - getActive: publicProcedure.query(() => { - const { lastActiveWorkspaceId } = db.data.settings; - - if (!lastActiveWorkspaceId) { - return null; - } - - const workspace = db.data.workspaces.find( - (w) => w.id === lastActiveWorkspaceId, - ); - if (!workspace) { - throw new Error( - `Active workspace ${lastActiveWorkspaceId} not found in database`, - ); - } - - return workspace; + const workspace = db.data.workspaces.find((w) => w.isActive); + return workspace || null; }), update: publicProcedure @@ -197,77 +136,6 @@ export const createWorkspacesRouter = () => { return { success: true }; }), - canDelete: publicProcedure - .input(z.object({ id: z.string() })) - .query(async ({ input }) => { - const workspace = db.data.workspaces.find((w) => w.id === input.id); - - if (!workspace) { - return { - canDelete: false, - reason: "Workspace not found", - workspace: null, - }; - } - - const worktree = db.data.worktrees.find( - (wt) => wt.id === workspace.worktreeId, - ); - const project = db.data.projects.find( - (p) => p.id === workspace.projectId, - ); - - if (worktree && project) { - try { - const gitInstance = simpleGit(project.mainRepoPath); - const worktrees = await gitInstance.raw([ - "worktree", - "list", - "--porcelain", - ]); - - // Parse porcelain format to verify worktree exists in git before deletion - // (porcelain format: "worktree /path/to/worktree" followed by HEAD, branch, etc.) - const lines = worktrees.split("\n"); - const worktreePrefix = `worktree ${worktree.path}`; - const worktreeExists = lines.some( - (line) => line.trim() === worktreePrefix, - ); - - if (!worktreeExists) { - // Worktree doesn't exist in git, but we can still delete the workspace - return { - canDelete: true, - reason: null, - workspace, - warning: - "Worktree not found in git (may have been manually removed)", - }; - } - - return { - canDelete: true, - reason: null, - workspace, - warning: null, - }; - } catch (error) { - return { - canDelete: false, - reason: `Failed to check worktree status: ${error instanceof Error ? error.message : String(error)}`, - workspace, - }; - } - } - - return { - canDelete: true, - reason: null, - workspace, - warning: "No associated worktree found", - }; - }), - delete: publicProcedure .input(z.object({ id: z.string() })) .mutation(async ({ input }) => { @@ -284,23 +152,34 @@ export const createWorkspacesRouter = () => { (p) => p.id === workspace.projectId, ); + // Try to remove worktree, but continue with cleanup even if it fails + let worktreeWarning: string | null = null; if (worktree && project) { try { await removeWorktree(project.mainRepoPath, worktree.path); } catch (error) { - // If worktree removal fails, return error and don't proceed with DB cleanup const errorMessage = error instanceof Error ? error.message : String(error); console.error("Failed to remove worktree:", errorMessage); - return { - success: false, - error: `Failed to remove worktree: ${errorMessage}`, - }; + worktreeWarning = `Workspace deleted from app, but couldn't remove git worktree at ${worktree.path}. You may need to manually clean it up if it still exists.`; } } - // Only proceed with DB cleanup if worktree was successfully removed (or doesn't exist) + const deletedTabIds = db.data.tabs + .filter((t) => t.workspaceId === input.id) + .map((t) => t.id); + + // Find adjacent workspace to activate (before deletion) if necessary + const adjacentWorkspace = workspace.isActive + ? findAdjacentWorkspace( + db.data.projects, + db.data.workspaces, + input.id, + ) + : undefined; + await db.update((data) => { + data.tabs = data.tabs.filter((t) => t.workspaceId !== input.id); data.workspaces = data.workspaces.filter((w) => w.id !== input.id); if (worktree) { @@ -321,27 +200,43 @@ export const createWorkspacesRouter = () => { } } - if (data.settings.lastActiveWorkspaceId === input.id) { - const sorted = data.workspaces - .slice() - .sort((a, b) => b.lastOpenedAt - a.lastOpenedAt); - data.settings.lastActiveWorkspaceId = sorted[0]?.id || undefined; + // Activate adjacent workspace if one was found + if (adjacentWorkspace) { + const ws = data.workspaces.find( + (w) => w.id === adjacentWorkspace.id, + ); + if (ws) { + ws.isActive = true; + } } }); - return { success: true }; + for (const tabId of deletedTabIds) { + terminalManager.kill({ tabId }); + } + + return { + success: true, + warning: worktreeWarning, + }; }), setActive: publicProcedure .input(z.object({ id: z.string() })) .mutation(async ({ input }) => { await db.update((data) => { + // Deactivate all workspaces + for (const ws of data.workspaces) { + ws.isActive = false; + } + + // Activate target workspace const workspace = data.workspaces.find((w) => w.id === input.id); if (!workspace) { throw new Error(`Workspace ${input.id} not found`); } - data.settings.lastActiveWorkspaceId = input.id; + workspace.isActive = true; workspace.lastOpenedAt = Date.now(); workspace.updatedAt = Date.now(); }); diff --git a/apps/desktop/src/main/lib/db/schemas.ts b/apps/desktop/src/main/lib/db/schemas.ts index 91da642c8c5..2857339b65c 100644 --- a/apps/desktop/src/main/lib/db/schemas.ts +++ b/apps/desktop/src/main/lib/db/schemas.ts @@ -1,3 +1,5 @@ +import type { MosaicNode } from "react-mosaic-component"; + export interface Project { id: string; mainRepoPath: string; @@ -22,34 +24,41 @@ export interface Workspace { worktreeId: string; name: string; tabOrder: number; + activeTabId?: string; + isActive: boolean; createdAt: number; updatedAt: number; lastOpenedAt: number; } -export interface Tab { +// Shared fields for all tab types +export interface BaseTab { id: string; + workspaceId: string; title: string; - terminalId?: string; - type: "single" | "group"; + position: number; + parentId?: string; + layout?: MosaicNode; + needsAttention?: boolean; createdAt: number; updatedAt: number; } -export interface Settings { - lastActiveWorkspaceId?: string; -} +// Discriminated union for type safety (future-proof for other tab types) +export type Tab = + | (BaseTab & { type: "terminal" }) + | (BaseTab & { type: "group" }); export interface Database { projects: Project[]; worktrees: Worktree[]; workspaces: Workspace[]; - settings: Settings; + tabs: Tab[]; } export const defaultDatabase: Database = { projects: [], worktrees: [], workspaces: [], - settings: {}, + tabs: [], }; diff --git a/apps/desktop/src/renderer/react-query/projects/useReorderProjects.ts b/apps/desktop/src/renderer/react-query/projects/useReorderProjects.ts index 03b39ed32b3..cdfbe95524a 100644 --- a/apps/desktop/src/renderer/react-query/projects/useReorderProjects.ts +++ b/apps/desktop/src/renderer/react-query/projects/useReorderProjects.ts @@ -5,7 +5,7 @@ export function useReorderProjects() { return trpc.projects.reorder.useMutation({ onSuccess: () => { - utils.workspaces.getAllGrouped.invalidate(); + utils.projects.getAllWithWorkspaces.invalidate(); }, }); } diff --git a/apps/desktop/src/renderer/react-query/tabs/index.ts b/apps/desktop/src/renderer/react-query/tabs/index.ts new file mode 100644 index 00000000000..a0aeb35cfbb --- /dev/null +++ b/apps/desktop/src/renderer/react-query/tabs/index.ts @@ -0,0 +1,11 @@ +export { useCreateTab } from "./useCreateTab"; +export { useRemoveTab } from "./useRemoveTab"; +export { useUpdateTab } from "./useUpdateTab"; +export { useSetActiveTab } from "./useSetActiveTab"; +export { useUpdateLayout } from "./useUpdateLayout"; +export { useSetParent } from "./useSetParent"; +export { useSplit } from "./useSplit"; +export { useSplitActiveTab } from "./useSplitActiveTab"; +export { useUngroup } from "./useUngroup"; +export { useReorder } from "./useReorder"; +export { useMoveOutOfGroup } from "./useMoveOutOfGroup"; diff --git a/apps/desktop/src/renderer/react-query/tabs/useCreateTab.ts b/apps/desktop/src/renderer/react-query/tabs/useCreateTab.ts new file mode 100644 index 00000000000..68398463c9c --- /dev/null +++ b/apps/desktop/src/renderer/react-query/tabs/useCreateTab.ts @@ -0,0 +1,23 @@ +import { trpc } from "renderer/lib/trpc"; + +/** + * Mutation hook for creating a new tab + * Automatically invalidates tabs and workspace queries on success + */ +export function useCreateTab( + options?: Parameters[0], +) { + const utils = trpc.useUtils(); + + return trpc.tabs.create.useMutation({ + ...options, + onSuccess: async (...args) => { + // Auto-invalidate tab and workspace queries + await utils.tabs.invalidate(); + await utils.workspaces.invalidate(); + + // Call user's onSuccess if provided + await options?.onSuccess?.(...args); + }, + }); +} diff --git a/apps/desktop/src/renderer/react-query/tabs/useMoveOutOfGroup.ts b/apps/desktop/src/renderer/react-query/tabs/useMoveOutOfGroup.ts new file mode 100644 index 00000000000..65030fb4bb3 --- /dev/null +++ b/apps/desktop/src/renderer/react-query/tabs/useMoveOutOfGroup.ts @@ -0,0 +1,16 @@ +import { trpc } from "renderer/lib/trpc"; + +export function useMoveOutOfGroup( + options?: Parameters[0], +) { + const utils = trpc.useUtils(); + + return trpc.tabs.moveOutOfGroup.useMutation({ + ...options, + onSuccess: async (...args) => { + await utils.tabs.invalidate(); + await utils.workspaces.invalidate(); + await options?.onSuccess?.(...args); + }, + }); +} diff --git a/apps/desktop/src/renderer/react-query/tabs/useRemoveTab.ts b/apps/desktop/src/renderer/react-query/tabs/useRemoveTab.ts new file mode 100644 index 00000000000..9347a8159a6 --- /dev/null +++ b/apps/desktop/src/renderer/react-query/tabs/useRemoveTab.ts @@ -0,0 +1,23 @@ +import { trpc } from "renderer/lib/trpc"; + +/** + * Mutation hook for removing a tab + * Automatically invalidates tabs and workspace queries on success + */ +export function useRemoveTab( + options?: Parameters[0], +) { + const utils = trpc.useUtils(); + + return trpc.tabs.remove.useMutation({ + ...options, + onSuccess: async (...args) => { + // Auto-invalidate tab and workspace queries + await utils.tabs.invalidate(); + await utils.workspaces.invalidate(); + + // Call user's onSuccess if provided + await options?.onSuccess?.(...args); + }, + }); +} diff --git a/apps/desktop/src/renderer/react-query/tabs/useReorder.ts b/apps/desktop/src/renderer/react-query/tabs/useReorder.ts new file mode 100644 index 00000000000..6425be0156d --- /dev/null +++ b/apps/desktop/src/renderer/react-query/tabs/useReorder.ts @@ -0,0 +1,16 @@ +import { trpc } from "renderer/lib/trpc"; + +export function useReorder( + options?: Parameters[0], +) { + const utils = trpc.useUtils(); + + return trpc.tabs.reorder.useMutation({ + ...options, + onSuccess: async (...args) => { + await utils.tabs.invalidate(); + await utils.workspaces.invalidate(); + await options?.onSuccess?.(...args); + }, + }); +} diff --git a/apps/desktop/src/renderer/react-query/tabs/useSetActiveTab.ts b/apps/desktop/src/renderer/react-query/tabs/useSetActiveTab.ts new file mode 100644 index 00000000000..33a3fb58c97 --- /dev/null +++ b/apps/desktop/src/renderer/react-query/tabs/useSetActiveTab.ts @@ -0,0 +1,23 @@ +import { trpc } from "renderer/lib/trpc"; + +/** + * Mutation hook for setting the active tab + * Automatically invalidates tabs and workspace queries on success + */ +export function useSetActiveTab( + options?: Parameters[0], +) { + const utils = trpc.useUtils(); + + return trpc.tabs.setActive.useMutation({ + ...options, + onSuccess: async (...args) => { + // Auto-invalidate tab and workspace queries (both are affected) + await utils.tabs.invalidate(); + await utils.workspaces.invalidate(); + + // Call user's onSuccess if provided + await options?.onSuccess?.(...args); + }, + }); +} diff --git a/apps/desktop/src/renderer/react-query/tabs/useSetParent.ts b/apps/desktop/src/renderer/react-query/tabs/useSetParent.ts new file mode 100644 index 00000000000..c4c90f07a3e --- /dev/null +++ b/apps/desktop/src/renderer/react-query/tabs/useSetParent.ts @@ -0,0 +1,16 @@ +import { trpc } from "renderer/lib/trpc"; + +export function useSetParent( + options?: Parameters[0], +) { + const utils = trpc.useUtils(); + + return trpc.tabs.setParent.useMutation({ + ...options, + onSuccess: async (...args) => { + await utils.tabs.invalidate(); + await utils.workspaces.invalidate(); + await options?.onSuccess?.(...args); + }, + }); +} diff --git a/apps/desktop/src/renderer/react-query/tabs/useSplit.ts b/apps/desktop/src/renderer/react-query/tabs/useSplit.ts new file mode 100644 index 00000000000..29dc6529fef --- /dev/null +++ b/apps/desktop/src/renderer/react-query/tabs/useSplit.ts @@ -0,0 +1,16 @@ +import { trpc } from "renderer/lib/trpc"; + +export function useSplit( + options?: Parameters[0], +) { + const utils = trpc.useUtils(); + + return trpc.tabs.split.useMutation({ + ...options, + onSuccess: async (...args) => { + await utils.tabs.invalidate(); + await utils.workspaces.invalidate(); + await options?.onSuccess?.(...args); + }, + }); +} diff --git a/apps/desktop/src/renderer/react-query/tabs/useSplitActiveTab.ts b/apps/desktop/src/renderer/react-query/tabs/useSplitActiveTab.ts new file mode 100644 index 00000000000..0f1500fc9ff --- /dev/null +++ b/apps/desktop/src/renderer/react-query/tabs/useSplitActiveTab.ts @@ -0,0 +1,16 @@ +import { trpc } from "renderer/lib/trpc"; + +export function useSplitActiveTab( + options?: Parameters[0], +) { + const utils = trpc.useUtils(); + + return trpc.tabs.splitActiveTab.useMutation({ + ...options, + onSuccess: async (...args) => { + await utils.tabs.invalidate(); + await utils.workspaces.invalidate(); + await options?.onSuccess?.(...args); + }, + }); +} diff --git a/apps/desktop/src/renderer/react-query/tabs/useUngroup.ts b/apps/desktop/src/renderer/react-query/tabs/useUngroup.ts new file mode 100644 index 00000000000..e2120464aac --- /dev/null +++ b/apps/desktop/src/renderer/react-query/tabs/useUngroup.ts @@ -0,0 +1,16 @@ +import { trpc } from "renderer/lib/trpc"; + +export function useUngroup( + options?: Parameters[0], +) { + const utils = trpc.useUtils(); + + return trpc.tabs.ungroup.useMutation({ + ...options, + onSuccess: async (...args) => { + await utils.tabs.invalidate(); + await utils.workspaces.invalidate(); + await options?.onSuccess?.(...args); + }, + }); +} diff --git a/apps/desktop/src/renderer/react-query/tabs/useUpdateLayout.ts b/apps/desktop/src/renderer/react-query/tabs/useUpdateLayout.ts new file mode 100644 index 00000000000..0836105eceb --- /dev/null +++ b/apps/desktop/src/renderer/react-query/tabs/useUpdateLayout.ts @@ -0,0 +1,24 @@ +import { trpc } from "renderer/lib/trpc"; + +/** + * Mutation hook for updating a group tab's layout + * Automatically invalidates tabs and workspace queries on success + */ +export function useUpdateLayout( + options?: Parameters[0], +) { + const utils = trpc.useUtils(); + + return trpc.tabs.updateLayout.useMutation({ + ...options, + onSuccess: async (...args) => { + // Auto-invalidate tab and workspace queries + // (workspace activeTabId might change if a child tab was removed) + await utils.tabs.invalidate(); + await utils.workspaces.invalidate(); + + // Call user's onSuccess if provided + await options?.onSuccess?.(...args); + }, + }); +} diff --git a/apps/desktop/src/renderer/react-query/tabs/useUpdateTab.ts b/apps/desktop/src/renderer/react-query/tabs/useUpdateTab.ts new file mode 100644 index 00000000000..9aff869cbde --- /dev/null +++ b/apps/desktop/src/renderer/react-query/tabs/useUpdateTab.ts @@ -0,0 +1,22 @@ +import { trpc } from "renderer/lib/trpc"; + +/** + * Mutation hook for updating a tab (title, needsAttention, etc.) + * Automatically invalidates tabs queries on success + */ +export function useUpdateTab( + options?: Parameters[0], +) { + const utils = trpc.useUtils(); + + return trpc.tabs.update.useMutation({ + ...options, + onSuccess: async (...args) => { + // Auto-invalidate tab queries + await utils.tabs.invalidate(); + + // Call user's onSuccess if provided + await options?.onSuccess?.(...args); + }, + }); +} diff --git a/apps/desktop/src/renderer/react-query/workspaces/useReorderWorkspaces.ts b/apps/desktop/src/renderer/react-query/workspaces/useReorderWorkspaces.ts index 35bbe675ee4..96f65873094 100644 --- a/apps/desktop/src/renderer/react-query/workspaces/useReorderWorkspaces.ts +++ b/apps/desktop/src/renderer/react-query/workspaces/useReorderWorkspaces.ts @@ -13,7 +13,7 @@ export function useReorderWorkspaces( ...options, onSuccess: async (...args) => { await utils.workspaces.getAll.invalidate(); - await utils.workspaces.getAllGrouped.invalidate(); + await utils.projects.getAllWithWorkspaces.invalidate(); await options?.onSuccess?.(...args); }, }); diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/DeleteWorkspaceDialog.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/DeleteWorkspaceDialog.tsx index bbe640cebb0..a93ef118272 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/DeleteWorkspaceDialog.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/DeleteWorkspaceDialog.tsx @@ -9,7 +9,6 @@ import { AlertDialogTitle, } from "@superset/ui/alert-dialog"; import { useState } from "react"; -import { trpc } from "renderer/lib/trpc"; import { useDeleteWorkspace } from "renderer/react-query/workspaces"; interface DeleteWorkspaceDialogProps { @@ -26,55 +25,43 @@ export function DeleteWorkspaceDialog({ onOpenChange, }: DeleteWorkspaceDialogProps) { const [isDeleting, setIsDeleting] = useState(false); + const [error, setError] = useState(null); const deleteWorkspace = useDeleteWorkspace(); - // Query to check if workspace can be deleted - const { data: canDeleteData, isLoading } = trpc.workspaces.canDelete.useQuery( - { id: workspaceId }, - { enabled: open }, // Only run when dialog is open - ); - const handleDelete = async () => { setIsDeleting(true); + setError(null); try { - await deleteWorkspace.mutateAsync({ id: workspaceId }); + const result = await deleteWorkspace.mutateAsync({ id: workspaceId }); + if (result.warning) { + // Show warning to user but still close dialog + console.warn("Workspace deleted with warning:", result.warning); + // TODO: Show toast notification with warning + } onOpenChange(false); } catch (error) { + const errorMessage = + error instanceof Error ? error.message : "Failed to delete workspace"; + setError(errorMessage); console.error("Failed to delete workspace:", error); } finally { setIsDeleting(false); } }; - const canDelete = canDeleteData?.canDelete ?? true; - const reason = canDeleteData?.reason; - const warning = canDeleteData?.warning; - return ( Delete Workspace - {isLoading ? ( - Checking workspace status... - ) : !canDelete ? ( - - Cannot delete workspace: {reason} - - ) : ( - <> - Are you sure you want to delete "{workspaceName}"? - {warning && ( - - Warning: {warning} - - )} - - This will remove the workspace and its associated git - worktree. This action cannot be undone. - - + Are you sure you want to delete "{workspaceName}"? + + This will remove the workspace and its associated git worktree. + This action cannot be undone. + + {error && ( + {error} )} @@ -85,7 +72,7 @@ export function DeleteWorkspaceDialog({ e.preventDefault(); handleDelete(); }} - disabled={!canDelete || isDeleting || isLoading} + disabled={isDeleting} className="bg-destructive text-white hover:bg-destructive/90" > {isDeleting ? "Deleting..." : "Delete"} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroup.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroup.tsx index 41f3796c871..cc54afd32cc 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroup.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceGroup.tsx @@ -1,34 +1,20 @@ import { AnimatePresence, motion } from "framer-motion"; import { useState } from "react"; +import type { ProjectWithWorkspaces } from "main/lib/trpc/routers/projects"; import { WorkspaceGroupHeader } from "./WorkspaceGroupHeader"; import { WorkspaceItem } from "./WorkspaceItem"; -interface Workspace { - id: string; - projectId: string; - name: string; - tabOrder: number; -} - interface WorkspaceGroupProps { - projectId: string; - projectName: string; - projectColor: string; + project: ProjectWithWorkspaces; projectIndex: number; - workspaces: Workspace[]; - activeWorkspaceId: string | null; workspaceWidth: number; hoveredWorkspaceId: string | null; onWorkspaceHover: (id: string | null) => void; } export function WorkspaceGroup({ - projectId, - projectName, - projectColor, + project, projectIndex, - workspaces, - activeWorkspaceId, workspaceWidth, hoveredWorkspaceId, onWorkspaceHover, @@ -39,9 +25,9 @@ export function WorkspaceGroup({
{/* Project group badge */} setIsCollapsed(!isCollapsed)} @@ -51,13 +37,13 @@ export function WorkspaceGroup({
{(isCollapsed - ? workspaces.filter((w) => w.id === activeWorkspaceId) - : workspaces + ? project.workspaces.filter((w) => w.isActive) + : project.workspaces ).map((workspace, index) => ( onWorkspaceHover(workspace.id)} diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx index a8c521bf5f0..2c7e92bfa43 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/index.tsx @@ -10,9 +10,7 @@ const MAX_WORKSPACE_WIDTH = 160; const ADD_BUTTON_WIDTH = 48; export function WorkspacesTabs() { - const { data: groups = [] } = trpc.workspaces.getAllGrouped.useQuery(); - const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); - const activeWorkspaceId = activeWorkspace?.id || null; + const { data: groups = [] } = trpc.projects.getAllWithWorkspaces.useQuery(); const setActiveWorkspace = useSetActiveWorkspace(); const containerRef = useRef(null); const scrollRef = useRef(null); @@ -25,23 +23,24 @@ export function WorkspacesTabs() { // Flatten workspaces for keyboard navigation const allWorkspaces = groups.flatMap((group) => group.workspaces); + const activeWorkspace = allWorkspaces.find((w) => w.isActive); // Workspace switching shortcuts (work across groups) useHotkeys("meta+alt+left", () => { - if (!activeWorkspaceId) return; - const index = allWorkspaces.findIndex((w) => w.id === activeWorkspaceId); + if (!activeWorkspace) return; + const index = allWorkspaces.findIndex((w) => w.id === activeWorkspace.id); if (index > 0) { setActiveWorkspace.mutate({ id: allWorkspaces[index - 1].id }); } - }, [activeWorkspaceId, allWorkspaces, setActiveWorkspace]); + }, [activeWorkspace, allWorkspaces, setActiveWorkspace]); useHotkeys("meta+alt+right", () => { - if (!activeWorkspaceId) return; - const index = allWorkspaces.findIndex((w) => w.id === activeWorkspaceId); + if (!activeWorkspace) return; + const index = allWorkspaces.findIndex((w) => w.id === activeWorkspace.id); if (index < allWorkspaces.length - 1) { setActiveWorkspace.mutate({ id: allWorkspaces[index + 1].id }); } - }, [activeWorkspaceId, allWorkspaces, setActiveWorkspace]); + }, [activeWorkspace, allWorkspaces, setActiveWorkspace]); useEffect(() => { const checkScroll = () => { @@ -91,20 +90,16 @@ export function WorkspacesTabs() { ref={scrollRef} className="flex h-full overflow-x-auto hide-scrollbar gap-4" > - {groups.map((group, groupIndex) => ( - + {groups.map((project, projectIndex) => ( + - {groupIndex < groups.length - 1 && ( + {projectIndex < groups.length - 1 && (
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/DropOverlay.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/DropOverlay.tsx deleted file mode 100644 index eec0a8f4993..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/DropOverlay.tsx +++ /dev/null @@ -1,13 +0,0 @@ -interface DropOverlayProps { - message: string; -} - -export function DropOverlay({ message }: DropOverlayProps) { - return ( -
-
-

{message}

-
-
- ); -} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupTabView/GroupTabPane.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupTabView/GroupTabPane.tsx index d4e5ef147c0..1703890e1c1 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupTabView/GroupTabPane.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupTabView/GroupTabPane.tsx @@ -1,51 +1,40 @@ import type { MosaicBranch } from "react-mosaic-component"; import { MosaicWindow } from "react-mosaic-component"; import { HiMiniXMark } from "react-icons/hi2"; -import type { Tab } from "renderer/stores"; +import type { Tab } from "main/lib/trpc/routers/tabs"; +import { useSetActiveTab } from "renderer/react-query/tabs"; import { TabContentContextMenu } from "../TabContentContextMenu"; import { Terminal } from "../Terminal"; import { Button } from "@superset/ui/button"; interface GroupTabPaneProps { - tabId: string; path: MosaicBranch[]; - childTab: Tab; + childTab: Tab & { type: "terminal" }; isActive: boolean; - workspaceId: string; groupId: string; - splitTabHorizontal: ( - workspaceId: string, - sourceTabId?: string, - path?: MosaicBranch[], - ) => void; - splitTabVertical: ( - workspaceId: string, - sourceTabId?: string, - path?: MosaicBranch[], - ) => void; + splitTabHorizontal: (sourceTabId?: string, path?: MosaicBranch[]) => void; + splitTabVertical: (sourceTabId?: string, path?: MosaicBranch[]) => void; removeChildTabFromGroup: (groupId: string, tabId: string) => void; - setActiveTab: (workspaceId: string, tabId: string) => void; } export function GroupTabPane({ - tabId, path, childTab, isActive, - workspaceId, groupId, splitTabHorizontal, splitTabVertical, removeChildTabFromGroup, - setActiveTab, }: GroupTabPaneProps) { + const setActiveTabMutation = useSetActiveTab(); + const handleFocus = () => { - setActiveTab(workspaceId, tabId); + setActiveTabMutation.mutate({ tabId: childTab.id }); }; const handleCloseTab = (e: React.MouseEvent) => { e.stopPropagation(); - removeChildTabFromGroup(groupId, tabId); + removeChildTabFromGroup(groupId, childTab.id); }; return ( @@ -66,12 +55,12 @@ export function GroupTabPane({ className={isActive ? "mosaic-window-focused" : ""} > splitTabHorizontal(workspaceId, tabId, path)} - onSplitVertical={() => splitTabVertical(workspaceId, tabId, path)} - onClosePane={() => removeChildTabFromGroup(groupId, tabId)} + onSplitHorizontal={() => splitTabHorizontal(childTab.id, path)} + onSplitVertical={() => splitTabVertical(childTab.id, path)} + onClosePane={() => removeChildTabFromGroup(groupId, childTab.id)} >
- +
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupTabView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupTabView/index.tsx index 7cb555e5b81..740fb451816 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupTabView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/GroupTabView/index.tsx @@ -7,120 +7,125 @@ import { type MosaicBranch, type MosaicNode, } from "react-mosaic-component"; +import type { Tab } from "main/lib/trpc/routers/tabs"; import { dragDropManager } from "renderer/lib/dnd"; -import { - cleanLayout, - getChildTabIds, - type TabGroup, - useActiveTabIds, - useSetActiveTab, - useSplitTabHorizontal, - useSplitTabVertical, - useTabs, - useTabsStore, -} from "renderer/stores"; +import { trpc } from "renderer/lib/trpc"; +import { useUpdateLayout, useSplit } from "renderer/react-query/tabs"; import { GroupTabPane } from "./GroupTabPane"; interface GroupTabViewProps { - tab: TabGroup; + tab: Tab & { type: "group" }; } -function extractTabIdsFromLayout( - layout: MosaicNode | null, -): Set { - const ids = new Set(); - - if (!layout) return ids; +// Helper to clean layout - remove tab IDs that don't exist +function cleanLayout( + layout: MosaicNode | null | undefined, + validTabIds: Set, +): MosaicNode | null { + if (!layout) return null; if (typeof layout === "string") { - ids.add(layout); - } else { - const firstIds = extractTabIdsFromLayout(layout.first); - const secondIds = extractTabIdsFromLayout(layout.second); - for (const id of firstIds) ids.add(id); - for (const id of secondIds) ids.add(id); + return validTabIds.has(layout) ? layout : null; } - return ids; + const cleanedFirst = cleanLayout(layout.first, validTabIds); + const cleanedSecond = cleanLayout(layout.second, validTabIds); + + if (!cleanedFirst && !cleanedSecond) return null; + if (!cleanedFirst) return cleanedSecond; + if (!cleanedSecond) return cleanedFirst; + + return { + ...layout, + first: cleanedFirst, + second: cleanedSecond, + }; } export function GroupTabView({ tab }: GroupTabViewProps) { - const allTabs = useTabs(); - const childTabIds = getChildTabIds(allTabs, tab.id); - const childTabs = allTabs.filter((t) => childTabIds.includes(t.id)); - const updateTabGroupLayout = useTabsStore( - (state) => state.updateTabGroupLayout, - ); - const removeChildTabFromGroup = useTabsStore( - (state) => state.removeChildTabFromGroup, + const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); + const { data: allTabs = [] } = trpc.tabs.getByWorkspace.useQuery( + { workspaceId: tab.workspaceId }, + { enabled: !!tab.workspaceId }, ); - const splitTabHorizontal = useSplitTabHorizontal(); - const splitTabVertical = useSplitTabVertical(); - const setActiveTab = useSetActiveTab(); - const activeTabIds = useActiveTabIds(); - const activeTabId = activeTabIds[tab.workspaceId]; + const updateLayoutMutation = useUpdateLayout(); + const splitMutation = useSplit(); - const validTabIds = new Set(childTabIds); + const childTabs = allTabs.filter((t) => t.parentId === tab.id); + const validTabIds = new Set(childTabs.map((t) => t.id)); const cleanedLayout = cleanLayout(tab.layout, validTabIds); const handleLayoutChange = useCallback( (newLayout: MosaicNode | null) => { - const oldTabIds = extractTabIdsFromLayout(tab.layout); - const newTabIds = extractTabIdsFromLayout(newLayout); - - const removedTabIds = Array.from(oldTabIds).filter( - (id) => !newTabIds.has(id), - ); - - for (const removedId of removedTabIds) { - removeChildTabFromGroup(tab.id, removedId); - } - - if (newLayout) { - updateTabGroupLayout(tab.id, newLayout); - } + // Just call the backend - it handles everything: + // - Extracting old/new tab IDs + // - Removing orphaned tabs + // - Killing terminals + // - Updating activeTabId if needed + updateLayoutMutation.mutate({ + groupId: tab.id, + layout: newLayout, + }); }, - [tab.id, tab.layout, updateTabGroupLayout, removeChildTabFromGroup], + [tab.id, updateLayoutMutation], ); - const renderPane = useCallback( - (tabId: string, path: MosaicBranch[]) => { - const isActive = tabId === activeTabId; - const childTab = childTabs.find((t) => t.id === tabId); - if (!childTab) { - return ( -
- Tab not found: {tabId} -
- ); - } - + const handleSplitHorizontal = ( + sourceTabId?: string, + path?: MosaicBranch[], + ) => { + if (sourceTabId && path) { + splitMutation.mutate({ + tabId: sourceTabId, + direction: "column", // Horizontal split = column direction + path, + }); + } + }; + + const handleSplitVertical = (sourceTabId?: string, path?: MosaicBranch[]) => { + if (sourceTabId && path) { + splitMutation.mutate({ + tabId: sourceTabId, + direction: "row", // Vertical split = row direction + path, + }); + } + }; + + const handleRemoveChild = (groupId: string, tabId: string) => { + // The Mosaic onChange will handle this automatically when user closes a pane + // But we can also call updateLayout directly if needed + console.log("Remove child will be handled by Mosaic onChange", { + groupId, + tabId, + }); + }; + + const renderPane = (tabId: string, path: MosaicBranch[]) => { + const isActive = tabId === activeWorkspace?.activeTabId; + const childTab = childTabs.find((t) => t.id === tabId); + + if (!childTab || childTab.type !== "terminal") { return ( - +
+ Tab not found: {tabId} +
); - }, - [ - activeTabId, - childTabs, - splitTabHorizontal, - splitTabVertical, - removeChildTabFromGroup, - setActiveTab, - tab.workspaceId, - tab.id, - ], - ); + } + + return ( + + ); + }; if (childTabs.length === 0 || !cleanedLayout) { return ( diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SingleTabView.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SingleTabView.tsx index cd646df9ca8..956594a7f52 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SingleTabView.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/SingleTabView.tsx @@ -1,38 +1,32 @@ -import { - type SingleTab, - useRemoveTab, - useSetActiveTab, - useSplitTabHorizontal, - useSplitTabVertical, -} from "renderer/stores"; +import type { Tab } from "main/lib/trpc/routers/tabs"; +import { useRemoveTab, useSplit } from "renderer/react-query/tabs"; import { TabContentContextMenu } from "./TabContentContextMenu"; import { Terminal } from "./Terminal"; interface SingleTabViewProps { - tab: SingleTab; - isDropZone: boolean; + tab: Tab & { type: "terminal" }; } export function SingleTabView({ tab }: SingleTabViewProps) { - const splitTabHorizontal = useSplitTabHorizontal(); - const splitTabVertical = useSplitTabVertical(); - const removeTab = useRemoveTab(); - const setActiveTab = useSetActiveTab(); + const removeTabMutation = useRemoveTab(); + const splitMutation = useSplit(); const handleSplitHorizontal = () => { - splitTabHorizontal(tab.workspaceId, tab.id); + splitMutation.mutate({ + tabId: tab.id, + direction: "column", // Horizontal split = column direction in Mosaic + }); }; const handleSplitVertical = () => { - splitTabVertical(tab.workspaceId, tab.id); + splitMutation.mutate({ + tabId: tab.id, + direction: "row", // Vertical split = row direction in Mosaic + }); }; const handleClosePane = () => { - removeTab(tab.id); - }; - - const handleFocus = () => { - setActiveTab(tab.workspaceId, tab.id); + removeTabMutation.mutate({ id: tab.id }); }; return ( @@ -42,7 +36,7 @@ export function SingleTabView({ tab }: SingleTabViewProps) { onClosePane={handleClosePane} >
- +
); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx index a9a09d68aeb..6841b53e067 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/Terminal.tsx @@ -3,7 +3,7 @@ import type { FitAddon } from "@xterm/addon-fit"; import type { Terminal as XTerm } from "@xterm/xterm"; import { useEffect, useRef, useState } from "react"; import { trpc } from "renderer/lib/trpc"; -import { useSetActiveTab, useTabs } from "renderer/stores"; +import { useSetActiveTab } from "renderer/react-query/tabs"; import { createTerminalInstance, setupFocusListener, @@ -11,17 +11,17 @@ import { } from "./helpers"; import type { TerminalProps, TerminalStreamEvent } from "./types"; -export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { - const tabs = useTabs(); - const tab = tabs.find((t) => t.id === tabId); - const tabTitle = tab?.title || "Terminal"; +export const Terminal = ({ tab }: TerminalProps) => { + const tabId = tab.id; + const workspaceId = tab.workspaceId; + const tabTitle = tab.title || "Terminal"; const terminalRef = useRef(null); const xtermRef = useRef(null); const fitAddonRef = useRef(null); const isExitedRef = useRef(false); const pendingEventsRef = useRef([]); const [subscriptionEnabled, setSubscriptionEnabled] = useState(false); - const setActiveTab = useSetActiveTab(); + const setActiveTabMutation = useSetActiveTab(); // Get the workspace CWD for resolving relative file paths const { data: workspaceCwd } = @@ -169,11 +169,8 @@ export const Terminal = ({ tabId, workspaceId }: TerminalProps) => { ); const inputDisposable = xterm.onData(handleTerminalInput); - const cleanupFocus = setupFocusListener( - xterm, - workspaceId, - tabId, - setActiveTab, + const cleanupFocus = setupFocusListener(xterm, tabId, (tabId) => + setActiveTabMutation.mutate({ tabId }), ); const cleanupResize = setupResizeHandlers( container, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts index da695f26808..f5a7fd8dd15 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/helpers.ts @@ -71,15 +71,14 @@ export function createTerminalInstance( export function setupFocusListener( xterm: XTerm, - workspaceId: string, tabId: string, - setActiveTab: (workspaceId: string, tabId: string) => void, + setActiveTab: (tabId: string) => void, ): (() => void) | null { const textarea = xterm.textarea; if (!textarea) return null; const handleFocus = () => { - setActiveTab(workspaceId, tabId); + setActiveTab(tabId); }; textarea.addEventListener("focus", handleFocus); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/types.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/types.ts index 29e41dff6d5..39bd6e3f3cd 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/types.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/Terminal/types.ts @@ -1,6 +1,7 @@ +import type { Tab } from "main/lib/trpc/routers/tabs"; + export interface TerminalProps { - tabId: string; - workspaceId: string; + tab: Tab & { type: "terminal" }; } export type TerminalStreamEvent = diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx index 824c4edc1ca..7356be81404 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/index.tsx @@ -1,56 +1,47 @@ -import { useMemo } from "react"; import { trpc } from "renderer/lib/trpc"; -import { TabType, useActiveTabIds, useTabs } from "renderer/stores"; -import { DropOverlay } from "./DropOverlay"; import { EmptyTabView } from "./EmptyTabView"; import { GroupTabView } from "./GroupTabView"; import { SingleTabView } from "./SingleTabView"; -import { useTabContentDrop } from "./useTabContentDrop"; export function TabsContent() { const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); const activeWorkspaceId = activeWorkspace?.id; - const allTabs = useTabs(); - const activeTabIds = useActiveTabIds(); - - const tabToRender = useMemo(() => { - if (!activeWorkspaceId) return null; - const activeTabId = activeTabIds[activeWorkspaceId]; - if (!activeTabId) return null; - - const activeTab = allTabs.find((tab) => tab.id === activeTabId); - if (!activeTab) return null; + const { data: allTabs = [] } = trpc.tabs.getByWorkspace.useQuery( + { workspaceId: activeWorkspaceId! }, + { enabled: !!activeWorkspaceId }, + ); - if (activeTab.parentId) { - const parentGroup = allTabs.find((tab) => tab.id === activeTab.parentId); - return parentGroup || null; + let tabToRender = null; + if (activeWorkspace?.activeTabId) { + const activeTab = allTabs.find( + (tab) => tab.id === activeWorkspace.activeTabId, + ); + if (activeTab) { + if (activeTab.parentId) { + const parentGroup = allTabs.find( + (tab) => tab.id === activeTab.parentId, + ); + tabToRender = parentGroup || null; + } else { + tabToRender = activeTab; + } } - - return activeTab; - }, [activeWorkspaceId, activeTabIds, allTabs]); - - const { isDropZone, attachDrop } = useTabContentDrop(tabToRender); + } if (!tabToRender) { return ( -
+
); } return ( -
- {tabToRender.type === TabType.Single ? ( - <> - - {isDropZone && } - +
+ {tabToRender.type === "terminal" ? ( + ) : ( - <> - - {isDropZone && } - + )}
); diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/useTabContentDrop.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/useTabContentDrop.ts deleted file mode 100644 index c86fb150e23..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/ContentView/TabsContent/useTabContentDrop.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { useDrop } from "react-dnd"; -import type { Tab } from "renderer/stores"; -import { useTabsStore } from "renderer/stores"; -import { type DragItem, TAB_DND_TYPE } from "./types"; - -export function useTabContentDrop(tabToRender: Tab | null) { - const dragTabToTab = useTabsStore((state) => state.dragTabToTab); - - const [{ isOver, canDrop }, drop] = useDrop< - DragItem, - void, - { isOver: boolean; canDrop: boolean } - >({ - accept: TAB_DND_TYPE, - drop: (item) => { - if (tabToRender) { - dragTabToTab(item.tabId, tabToRender.id); - } - }, - canDrop: () => { - return tabToRender !== null; - }, - collect: (monitor) => ({ - isOver: monitor.isOver(), - canDrop: monitor.canDrop(), - }), - }); - - const isDropZone = isOver && canDrop; - - const attachDrop = (node: HTMLDivElement | null) => { - if (node) drop(node); - }; - - return { isDropZone, attachDrop }; -} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/index.tsx index 6d723af4dd2..4347801cfc0 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/index.tsx @@ -3,51 +3,42 @@ import { useState } from "react"; import { HiChevronRight, HiMiniXMark } from "react-icons/hi2"; import { trpc } from "renderer/lib/trpc"; import { - useActiveTabIds, useRemoveTab, useSetActiveTab, - useTabs, - useUngroupTab, - useUngroupTabs, -} from "renderer/stores"; -import { TabType } from "renderer/stores/tabs/types"; + useUngroup, + useMoveOutOfGroup, +} from "renderer/react-query/tabs"; import { TabContextMenu } from "./TabContextMenu"; import type { TabItemProps } from "./types"; import { useDragTab } from "./useDragTab"; -import { useGroupDrop } from "./useGroupDrop"; import { useTabRename } from "./useTabRename"; export function TabItem({ tab, childTabs = [] }: TabItemProps) { const [isExpanded, setIsExpanded] = useState(true); const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); - const activeWorkspaceId = activeWorkspace?.id; - const activeTabIds = useActiveTabIds(); - const removeTab = useRemoveTab(); - const setActiveTab = useSetActiveTab(); - const ungroupTabs = useUngroupTabs(); - const ungroupTab = useUngroupTab(); - const tabs = useTabs(); + const { data: allTabs = [] } = trpc.tabs.getByWorkspace.useQuery( + { workspaceId: activeWorkspace?.id! }, + { enabled: !!activeWorkspace?.id }, + ); + const removeTabMutation = useRemoveTab(); + const setActiveTabMutation = useSetActiveTab(); + const ungroupMutation = useUngroup(); + const moveOutOfGroupMutation = useMoveOutOfGroup(); - const activeTabId = activeWorkspaceId - ? activeTabIds[activeWorkspaceId] - : null; - const isActive = tab.id === activeTabId; + const isActive = tab.id === activeWorkspace?.activeTabId; const { drag, drop, isDragging, isDragOver } = useDragTab(tab.id); - const groupDrop = useGroupDrop(tab.id); const rename = useTabRename(tab.id, tab.title); const handleRemoveTab = (e?: React.MouseEvent) => { e?.stopPropagation(); - removeTab(tab.id); + removeTabMutation.mutate({ id: tab.id }); }; const handleTabClick = () => { if (rename.isRenaming) return; - if (activeWorkspaceId) { - setActiveTab(activeWorkspaceId, tab.id); - } + setActiveTabMutation.mutate({ tabId: tab.id }); }; const handleToggleExpand = (e: React.MouseEvent) => { @@ -58,21 +49,24 @@ export function TabItem({ tab, childTabs = [] }: TabItemProps) { }; const handleUngroup = () => { - ungroupTabs(tab.id); + ungroupMutation.mutate({ groupId: tab.id }); }; const handleMoveOutOfGroup = () => { if (!tab.parentId) return; // Find the parent group's index in the workspace tabs - const workspaceTabs = tabs.filter( + const workspaceTabs = allTabs.filter( (t) => t.workspaceId === tab.workspaceId && !t.parentId, ); const parentIndex = workspaceTabs.findIndex((t) => t.id === tab.parentId); // Place after the parent (parentIndex + 1) if (parentIndex !== -1) { - ungroupTab(tab.id, parentIndex + 1); + moveOutOfGroupMutation.mutate({ + tabId: tab.id, + targetIndex: parentIndex + 1, + }); } }; @@ -81,7 +75,7 @@ export function TabItem({ tab, childTabs = [] }: TabItemProps) { drop(el); }; - const isGroupTab = tab.type === TabType.Group; + const isGroupTab = tab.type === "group"; const hasChildren = childTabs.length > 0; return ( @@ -183,15 +177,7 @@ export function TabItem({ tab, childTabs = [] }: TabItemProps) { {isGroupTab && hasChildren && isExpanded && ( -
{ - groupDrop.drop(node); - }} - className="ml-4 mt-1 space-y-1 relative" - > - {groupDrop.isDragOver && ( -
- )} +
{childTabs.map((childTab) => { return (
diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/useDragTab.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/useDragTab.ts index df3cc6b779e..bb30a45e7ef 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/useDragTab.ts +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/useDragTab.ts @@ -1,10 +1,7 @@ import { useDrag, useDrop } from "react-dnd"; -import { useTabsStore } from "renderer/stores"; import { type DragItem, TAB_DND_TYPE } from "./types"; export function useDragTab(tabId: string) { - const dragTabToTab = useTabsStore((state) => state.dragTabToTab); - // Set up drag source const [{ isDragging }, drag] = useDrag< DragItem, @@ -27,7 +24,11 @@ export function useDragTab(tabId: string) { accept: TAB_DND_TYPE, drop: (item) => { if (item.tabId !== tabId) { - dragTabToTab(item.tabId, tabId); + // TODO: Implement drag-tab-to-tab with tRPC mutations + console.log("Drag tab to tab not yet implemented", { + draggedTabId: item.tabId, + targetTabId: tabId, + }); } }, collect: (monitor) => ({ diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/useGroupDrop.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/useGroupDrop.ts deleted file mode 100644 index 12cf792cbf0..00000000000 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabItem/useGroupDrop.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useDrop } from "react-dnd"; -import { useTabsStore } from "renderer/stores"; -import { type DragItem, TAB_DND_TYPE } from "./types"; - -export function useGroupDrop(groupId: string) { - const dragTabToTab = useTabsStore((state) => state.dragTabToTab); - - const [{ isOver, canDrop }, drop] = useDrop< - DragItem, - void, - { isOver: boolean; canDrop: boolean } - >({ - accept: TAB_DND_TYPE, - drop: (item, monitor) => { - // Only drop if not already handled by a child - const didDrop = monitor.didDrop(); - if (!didDrop && item.tabId !== groupId) { - dragTabToTab(item.tabId, groupId); - } - }, - collect: (monitor) => ({ - isOver: monitor.isOver({ shallow: true }), - canDrop: monitor.canDrop(), - }), - }); - - const isDragOver = isOver && canDrop; - - return { drop, isDragOver }; -} diff --git a/apps/desktop/src/renderer/screens/main/index.tsx b/apps/desktop/src/renderer/screens/main/index.tsx index f1d8c1d3a15..fe6f593fdd8 100644 --- a/apps/desktop/src/renderer/screens/main/index.tsx +++ b/apps/desktop/src/renderer/screens/main/index.tsx @@ -2,11 +2,8 @@ import { DndProvider } from "react-dnd"; import { useHotkeys } from "react-hotkeys-hook"; import { trpc } from "renderer/lib/trpc"; import { useSidebarStore } from "renderer/stores/sidebar-state"; -import { - useAgentHookListener, - useSplitTabHorizontal, - useSplitTabVertical, -} from "renderer/stores/tabs"; +import { useAgentHookListener } from "renderer/stores/tabs"; +import { useSplitActiveTab } from "renderer/react-query/tabs"; import { dragDropManager } from "../../lib/dnd"; import { AppFrame } from "./components/AppFrame"; import { Background } from "./components/Background"; @@ -16,29 +13,40 @@ import { WorkspaceView } from "./components/WorkspaceView"; export function MainScreen() { const { toggleSidebar } = useSidebarStore(); const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); - const splitTabVertical = useSplitTabVertical(); - const splitTabHorizontal = useSplitTabHorizontal(); + const splitActiveTabMutation = useSplitActiveTab(); // Listen for agent completion hooks from main process useAgentHookListener(); - const activeWorkspaceId = activeWorkspace?.id; - // Sidebar toggle shortcut useHotkeys("meta+s", toggleSidebar, [toggleSidebar]); // Split view shortcuts - useHotkeys("meta+d", () => { - if (activeWorkspaceId) { - splitTabVertical(activeWorkspaceId); - } - }, [activeWorkspaceId, splitTabVertical]); + useHotkeys( + "meta+d", + () => { + if (activeWorkspace?.id) { + splitActiveTabMutation.mutate({ + workspaceId: activeWorkspace.id, + direction: "row", // Vertical split + }); + } + }, + [activeWorkspace?.id, splitActiveTabMutation], + ); - useHotkeys("meta+shift+d", () => { - if (activeWorkspaceId) { - splitTabHorizontal(activeWorkspaceId); - } - }, [activeWorkspaceId, splitTabHorizontal]); + useHotkeys( + "meta+shift+d", + () => { + if (activeWorkspace?.id) { + splitActiveTabMutation.mutate({ + workspaceId: activeWorkspace.id, + direction: "column", // Horizontal split + }); + } + }, + [activeWorkspace?.id, splitActiveTabMutation], + ); return ( diff --git a/apps/desktop/src/renderer/stores/tabs/drag-logic.test.ts b/apps/desktop/src/renderer/stores/tabs/drag-logic.test.ts deleted file mode 100644 index 250053f268b..00000000000 --- a/apps/desktop/src/renderer/stores/tabs/drag-logic.test.ts +++ /dev/null @@ -1,1081 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import type { MosaicNode } from "react-mosaic-component"; -import { cleanLayout, handleDragTabToTab } from "./drag-logic"; -import { type Tab, TabType } from "./types"; - -describe("cleanLayout", () => { - test("removes invalid tab IDs from layout", () => { - const layout: MosaicNode = { - direction: "row", - first: "tab-1", - second: "tab-2", - splitPercentage: 50, - }; - - const validTabIds = new Set(["tab-1"]); - const result = cleanLayout(layout, validTabIds); - - expect(result).toBe("tab-1"); - }); - - test("preserves valid nested layout", () => { - const layout: MosaicNode = { - direction: "row", - first: "tab-1", - second: { - direction: "column", - first: "tab-2", - second: "tab-3", - splitPercentage: 50, - }, - splitPercentage: 50, - }; - - const validTabIds = new Set(["tab-1", "tab-2", "tab-3"]); - const result = cleanLayout(layout, validTabIds); - - expect(result).toEqual(layout); - }); - - test("collapses layout when one branch is invalid", () => { - const layout: MosaicNode = { - direction: "row", - first: "tab-invalid", - second: { - direction: "column", - first: "tab-2", - second: "tab-3", - splitPercentage: 50, - }, - splitPercentage: 50, - }; - - const validTabIds = new Set(["tab-2", "tab-3"]); - const result = cleanLayout(layout, validTabIds); - - expect(result).toEqual({ - direction: "column", - first: "tab-2", - second: "tab-3", - splitPercentage: 50, - }); - }); - - test("returns null when all tabs are invalid", () => { - const layout: MosaicNode = { - direction: "row", - first: "tab-invalid-1", - second: "tab-invalid-2", - splitPercentage: 50, - }; - - const validTabIds = new Set(["tab-valid"]); - const result = cleanLayout(layout, validTabIds); - - expect(result).toBeNull(); - }); - - test("handles single tab ID layout", () => { - const layout: MosaicNode = "tab-1"; - const validTabIds = new Set(["tab-1"]); - const result = cleanLayout(layout, validTabIds); - - expect(result).toBe("tab-1"); - }); - - test("returns null for invalid single tab ID", () => { - const layout: MosaicNode = "tab-invalid"; - const validTabIds = new Set(["tab-valid"]); - const result = cleanLayout(layout, validTabIds); - - expect(result).toBeNull(); - }); -}); - -describe("handleDragTabToTab", () => { - const workspaceId = "workspace-1"; - - test("dragging single tab onto itself creates a group with new tab", () => { - const tab1: Tab = { - id: "tab-1", - title: "Tab 1", - workspaceId, - type: TabType.Single, - }; - - const state = { - tabs: [tab1], - activeTabIds: { [workspaceId]: "tab-1" }, - tabHistoryStacks: { [workspaceId]: [] }, - }; - - const result = handleDragTabToTab("tab-1", "tab-1", state); - - // Should create 3 tabs: original tab-1 (now with parent), new tab, new group - expect(result.tabs.length).toBe(3); - - // Find the tabs - const originalTab = result.tabs.find((t) => t.id === "tab-1"); - const newTab = result.tabs.find( - (t) => t.id !== "tab-1" && t.type === TabType.Single, - ); - const groupTab = result.tabs.find((t) => t.type === TabType.Group); - - // Original tab should now have a parent - expect(originalTab?.parentId).toBe(groupTab?.id); - - // New tab should also have the same parent - expect(newTab?.parentId).toBe(groupTab?.id); - - // Group should be active - expect(result.activeTabIds[workspaceId]).toBe(groupTab?.id ?? null); - - // Verify group layout contains both tabs - if (groupTab?.type === TabType.Group && newTab) { - expect(groupTab.layout).toEqual({ - direction: "row", - first: "tab-1", - second: newTab.id, - splitPercentage: 50, - }); - } - }); - - test("dragging child tab onto itself creates new tab in parent group", () => { - const groupTab: Tab = { - id: "group-1", - title: "Group", - workspaceId, - type: TabType.Group, - layout: "child-1", - }; - - const childTab: Tab = { - id: "child-1", - title: "Child", - workspaceId, - type: TabType.Single, - parentId: "group-1", - }; - - const state = { - tabs: [groupTab, childTab], - activeTabIds: { [workspaceId]: "group-1" }, - tabHistoryStacks: { [workspaceId]: [] }, - }; - - const result = handleDragTabToTab("child-1", "child-1", state); - - // Should create a new tab and add it to the group - expect(result.tabs.length).toBe(3); - - // Find the new tab - const newTab = result.tabs.find( - (t) => t.id !== "group-1" && t.id !== "child-1", - ); - expect(newTab).toBeDefined(); - expect(newTab?.parentId).toBe("group-1"); - - // Group layout should be updated - const updatedGroup = result.tabs.find((t) => t.id === "group-1"); - if (updatedGroup?.type === TabType.Group && newTab) { - expect(updatedGroup.layout).toEqual({ - direction: "row", - first: "child-1", - second: newTab.id, - splitPercentage: 50, - }); - } - - // Group should remain active - expect(result.activeTabIds[workspaceId]).toBe("group-1"); - }); - - test("dragging single tab into another single tab creates group with original IDs", () => { - const tab1: Tab = { - id: "tab-1", - title: "Tab 1", - workspaceId, - type: TabType.Single, - }; - - const tab2: Tab = { - id: "tab-2", - title: "Tab 2", - workspaceId, - type: TabType.Single, - }; - - const state = { - tabs: [tab1, tab2], - activeTabIds: { [workspaceId]: "tab-1" }, - tabHistoryStacks: { [workspaceId]: [] }, - }; - - const result = handleDragTabToTab("tab-2", "tab-1", state); - - // Should have 3 tabs: original tab-1, original tab-2, new group - expect(result.tabs.length).toBe(3); - - // Find the tabs - const originalTab1 = result.tabs.find((t) => t.id === "tab-1"); - const originalTab2 = result.tabs.find((t) => t.id === "tab-2"); - const groupTab = result.tabs.find((t) => t.type === TabType.Group); - - // Verify original tab IDs are preserved - expect(originalTab1).toBeDefined(); - expect(originalTab2).toBeDefined(); - expect(originalTab1?.id).toBe("tab-1"); - expect(originalTab2?.id).toBe("tab-2"); - - // Verify they now have a parent - expect(originalTab1?.parentId).toBe(groupTab?.id); - expect(originalTab2?.parentId).toBe(groupTab?.id); - - // Verify group layout contains both original IDs - expect(groupTab?.type).toBe(TabType.Group); - if (groupTab?.type === TabType.Group) { - expect(groupTab.layout).toEqual({ - direction: "row", - first: "tab-1", - second: "tab-2", - splitPercentage: 50, - }); - } - }); - - test("dragging single tab into group adds to group", () => { - const groupTab: Tab = { - id: "group-1", - title: "Group", - workspaceId, - type: TabType.Group, - layout: "child-1", - }; - - const childTab: Tab = { - id: "child-1", - title: "Child 1", - workspaceId, - type: TabType.Single, - parentId: "group-1", - }; - - const singleTab: Tab = { - id: "tab-2", - title: "Tab 2", - workspaceId, - type: TabType.Single, - }; - - const state = { - tabs: [groupTab, childTab, singleTab], - activeTabIds: { [workspaceId]: "group-1" }, - tabHistoryStacks: { [workspaceId]: [] }, - }; - - const result = handleDragTabToTab("tab-2", "group-1", state); - - // Find the updated group - const updatedGroup = result.tabs.find((t) => t.id === "group-1") as Tab & { - type: TabType.Group; - }; - - expect(updatedGroup).toBeDefined(); - expect(updatedGroup.type).toBe(TabType.Group); - expect(updatedGroup.layout).toEqual({ - direction: "row", - first: "child-1", - second: "tab-2", - splitPercentage: 50, - }); - - // Verify tab-2 now has the parent - const updatedTab2 = result.tabs.find((t) => t.id === "tab-2"); - expect(updatedTab2?.parentId).toBe("group-1"); - }); - - test("dragging tab from one group to another updates both groups", () => { - const group1: Tab = { - id: "group-1", - title: "Group 1", - workspaceId, - type: TabType.Group, - layout: { - direction: "row", - first: "child-1", - second: "child-2", - splitPercentage: 50, - }, - }; - - const child1: Tab = { - id: "child-1", - title: "Child 1", - workspaceId, - type: TabType.Single, - parentId: "group-1", - }; - - const child2: Tab = { - id: "child-2", - title: "Child 2", - workspaceId, - type: TabType.Single, - parentId: "group-1", - }; - - const group2: Tab = { - id: "group-2", - title: "Group 2", - workspaceId, - type: TabType.Group, - layout: "child-3", - }; - - const child3: Tab = { - id: "child-3", - title: "Child 3", - workspaceId, - type: TabType.Single, - parentId: "group-2", - }; - - const state = { - tabs: [group1, child1, child2, group2, child3], - activeTabIds: { [workspaceId]: "group-1" }, - tabHistoryStacks: { [workspaceId]: [] }, - }; - - // Drag child-2 from group-1 to group-2 - const result = handleDragTabToTab("child-2", "group-2", state); - - // Verify group-1 layout was cleaned - should only contain child-1 now - const updatedGroup1 = result.tabs.find((t) => t.id === "group-1") as Tab & { - type: TabType.Group; - }; - expect(updatedGroup1.layout).toBe("child-1"); - - // Verify group-2 layout was updated to include child-2 - const updatedGroup2 = result.tabs.find((t) => t.id === "group-2") as Tab & { - type: TabType.Group; - }; - expect(updatedGroup2.layout).toEqual({ - direction: "row", - first: "child-3", - second: "child-2", - splitPercentage: 50, - }); - - // Verify child-2 parent was updated - const updatedChild2 = result.tabs.find((t) => t.id === "child-2"); - expect(updatedChild2?.parentId).toBe("group-2"); - }); - - test("dragging into child tab redirects to parent group", () => { - const groupTab: Tab = { - id: "group-1", - title: "Group", - workspaceId, - type: TabType.Group, - layout: "child-1", - }; - - const childTab: Tab = { - id: "child-1", - title: "Child 1", - workspaceId, - type: TabType.Single, - parentId: "group-1", - }; - - const singleTab: Tab = { - id: "tab-2", - title: "Tab 2", - workspaceId, - type: TabType.Single, - }; - - const state = { - tabs: [groupTab, childTab, singleTab], - activeTabIds: { [workspaceId]: "group-1" }, - tabHistoryStacks: { [workspaceId]: [] }, - }; - - // Drag tab-2 onto child-1 (should redirect to group-1) - const result = handleDragTabToTab("tab-2", "child-1", state); - - const updatedGroup = result.tabs.find((t) => t.id === "group-1") as Tab & { - type: TabType.Group; - }; - - expect(updatedGroup.layout).toEqual({ - direction: "row", - first: "child-1", - second: "tab-2", - splitPercentage: 50, - }); - }); - - test("dragging tab from complex nested layout cleans correctly", () => { - // Group with 3-way split: (A | B) on top, C on bottom - const group1: Tab = { - id: "group-1", - title: "Group 1", - workspaceId, - type: TabType.Group, - layout: { - direction: "column", - first: { - direction: "row", - first: "child-1", - second: "child-2", - splitPercentage: 50, - }, - second: "child-3", - splitPercentage: 50, - }, - }; - - const child1: Tab = { - id: "child-1", - title: "Child 1", - workspaceId, - type: TabType.Single, - parentId: "group-1", - }; - - const child2: Tab = { - id: "child-2", - title: "Child 2", - workspaceId, - type: TabType.Single, - parentId: "group-1", - }; - - const child3: Tab = { - id: "child-3", - title: "Child 3", - workspaceId, - type: TabType.Single, - parentId: "group-1", - }; - - const group2: Tab = { - id: "group-2", - title: "Group 2", - workspaceId, - type: TabType.Group, - layout: "child-4", - }; - - const child4: Tab = { - id: "child-4", - title: "Child 4", - workspaceId, - type: TabType.Single, - parentId: "group-2", - }; - - const state = { - tabs: [group1, child1, child2, child3, group2, child4], - activeTabIds: { [workspaceId]: "group-1" }, - tabHistoryStacks: { [workspaceId]: [] }, - }; - - // Drag child-2 from group-1 to group-2 - const result = handleDragTabToTab("child-2", "group-2", state); - - // Group-1 should collapse the nested structure since child-2 is removed - const updatedGroup1 = result.tabs.find((t) => t.id === "group-1") as Tab & { - type: TabType.Group; - }; - - // Should collapse to just the remaining two tabs - expect(updatedGroup1.layout).toEqual({ - direction: "column", - first: "child-1", - second: "child-3", - splitPercentage: 50, - }); - - // Group-2 should add child-2 - const updatedGroup2 = result.tabs.find((t) => t.id === "group-2") as Tab & { - type: TabType.Group; - }; - expect(updatedGroup2.layout).toEqual({ - direction: "row", - first: "child-4", - second: "child-2", - splitPercentage: 50, - }); - - // Verify child-2 parent was updated - const updatedChild2 = result.tabs.find((t) => t.id === "child-2"); - expect(updatedChild2?.parentId).toBe("group-2"); - }); - - test("removing last tab from nested layout returns null", () => { - const layout: MosaicNode = { - direction: "column", - first: { - direction: "row", - first: "child-1", - second: "child-2", - splitPercentage: 50, - }, - second: "child-3", - splitPercentage: 50, - }; - - // Remove all tabs one by one - let cleaned = cleanLayout(layout, new Set(["child-2", "child-3"])); - expect(cleaned).toEqual({ - direction: "column", - first: "child-2", - second: "child-3", - splitPercentage: 50, - }); - - cleaned = cleanLayout(layout, new Set(["child-3"])); - expect(cleaned).toBe("child-3"); - - cleaned = cleanLayout(layout, new Set([])); - expect(cleaned).toBeNull(); - }); - - test("layout is cleaned when tab is moved to standalone (no longer has parent)", () => { - // This simulates dragging a child tab out to become a standalone tab - const groupTab: Tab = { - id: "group-1", - title: "Group", - workspaceId, - type: TabType.Group, - layout: { - direction: "row", - first: "child-1", - second: "child-2", - splitPercentage: 50, - }, - }; - - const child1: Tab = { - id: "child-1", - title: "Child 1", - workspaceId, - type: TabType.Single, - parentId: "group-1", - }; - - const child2: Tab = { - id: "child-2", - title: "Child 2", - workspaceId, - type: TabType.Single, - parentId: "group-1", - }; - - // Simulate the state AFTER dragging child-2 out - // In reality, some action would clear child-2's parentId - const stateAfterDrag: Tab[] = [ - groupTab, // Still has old layout with both child-1 and child-2 - child1, // Still has parent - { ...child2, parentId: undefined }, // Parent was cleared - ]; - - // Now verify that cleanLayout would remove child-2 from the layout - // since it's no longer a child - const childTabIds = stateAfterDrag - .filter((t) => t.parentId === "group-1") - .map((t) => t.id); - const validTabIds = new Set(childTabIds); - - expect(validTabIds.has("child-1")).toBe(true); - expect(validTabIds.has("child-2")).toBe(false); - - const cleanedLayout = cleanLayout(groupTab.layout, validTabIds); - - // Layout should collapse to just child-1 - expect(cleanedLayout).toBe("child-1"); - }); - - test("layout with invalid tab IDs is cleaned before rendering", () => { - // Simulate a group with a stale layout (contains tabs that no longer exist) - const layout: MosaicNode = { - direction: "row", - first: "valid-tab", - second: "deleted-tab", // This tab was removed but layout wasn't updated - splitPercentage: 50, - }; - - // Only valid-tab actually exists - const validTabIds = new Set(["valid-tab"]); - const cleaned = cleanLayout(layout, validTabIds); - - // Should collapse to just the valid tab - expect(cleaned).toBe("valid-tab"); - }); - - test("complex layout with multiple invalid tabs is fully cleaned", () => { - // Layout: ((A | B) / (C | D)) - // But only A and D still exist - const layout: MosaicNode = { - direction: "column", - first: { - direction: "row", - first: "tab-a", - second: "tab-b", // Removed - splitPercentage: 50, - }, - second: { - direction: "row", - first: "tab-c", // Removed - second: "tab-d", - splitPercentage: 50, - }, - splitPercentage: 50, - }; - - const validTabIds = new Set(["tab-a", "tab-d"]); - const cleaned = cleanLayout(layout, validTabIds); - - // Should collapse to just A and D - expect(cleaned).toEqual({ - direction: "column", - first: "tab-a", - second: "tab-d", - splitPercentage: 50, - }); - }); - - test("after dragging tab out, getChildTabIds correctly excludes it", () => { - // This tests the exact scenario that caused "Tab not found" - const groupTab: Tab = { - id: "group-1", - title: "Group", - workspaceId, - type: TabType.Group, - layout: { - direction: "row", - first: "child-1", - second: "child-2", - splitPercentage: 50, - }, - }; - - const child1: Tab = { - id: "child-1", - title: "Child 1", - workspaceId, - type: TabType.Single, - parentId: "group-1", - }; - - const child2: Tab = { - id: "child-2", - title: "Child 2", - workspaceId, - type: TabType.Single, - parentId: "group-1", - }; - - // Initial state - both children in group - const initialTabs = [groupTab, child1, child2]; - const initialChildIds = initialTabs - .filter((t) => t.parentId === "group-1") - .map((t) => t.id); - - expect(initialChildIds).toEqual(["child-1", "child-2"]); - - // After dragging child-2 out (parentId cleared) - const afterDragTabs = [ - groupTab, // Layout still has both children (stale) - child1, - { ...child2, parentId: undefined }, // Parent cleared - ]; - - const afterDragChildIds = afterDragTabs - .filter((t) => t.parentId === "group-1") - .map((t) => t.id); - - // Only child-1 should be returned - expect(afterDragChildIds).toEqual(["child-1"]); - - // The layout is stale but cleanLayout should fix it - const validIds = new Set(afterDragChildIds); - const cleaned = cleanLayout(groupTab.layout, validIds); - - // Layout should now only contain child-1 - expect(cleaned).toBe("child-1"); - }); - - test("dragging tab already in same group creates new tab in that group", () => { - const groupTab: Tab = { - id: "group-1", - title: "Group", - workspaceId, - type: TabType.Group, - layout: { - direction: "row", - first: "child-1", - second: "child-2", - splitPercentage: 50, - }, - }; - - const child1: Tab = { - id: "child-1", - title: "Child 1", - workspaceId, - type: TabType.Single, - parentId: "group-1", - }; - - const child2: Tab = { - id: "child-2", - title: "Child 2", - workspaceId, - type: TabType.Single, - parentId: "group-1", - }; - - const state = { - tabs: [groupTab, child1, child2], - activeTabIds: { [workspaceId]: "group-1" }, - tabHistoryStacks: { [workspaceId]: [] }, - }; - - // Drag child-1 onto its own parent group - const result = handleDragTabToTab("child-1", "group-1", state); - - // Should create a new tab and add it to the group - expect(result.tabs.length).toBe(4); - - // Find the new tab - const newTab = result.tabs.find( - (t) => t.id !== "group-1" && t.id !== "child-1" && t.id !== "child-2", - ); - expect(newTab).toBeDefined(); - expect(newTab?.parentId).toBe("group-1"); - - // Group layout should be updated to include new tab - const updatedGroup = result.tabs.find((t) => t.id === "group-1"); - if (updatedGroup?.type === TabType.Group && newTab) { - expect(updatedGroup.layout).toEqual({ - direction: "row", - first: { - direction: "row", - first: "child-1", - second: "child-2", - splitPercentage: 50, - }, - second: newTab.id, - splitPercentage: 50, - }); - } - - // Group should remain active - expect(result.activeTabIds[workspaceId]).toBe("group-1"); - }); - - test("dragging group onto itself creates new tab in that group", () => { - const groupTab: Tab = { - id: "group-1", - title: "Group", - workspaceId, - type: TabType.Group, - layout: { - direction: "row", - first: "child-1", - second: "child-2", - splitPercentage: 50, - }, - }; - - const child1: Tab = { - id: "child-1", - title: "Child 1", - workspaceId, - type: TabType.Single, - parentId: "group-1", - }; - - const child2: Tab = { - id: "child-2", - title: "Child 2", - workspaceId, - type: TabType.Single, - parentId: "group-1", - }; - - const state = { - tabs: [groupTab, child1, child2], - activeTabIds: { [workspaceId]: "group-1" }, - tabHistoryStacks: { [workspaceId]: [] }, - }; - - // Drag group-1 onto itself - const result = handleDragTabToTab("group-1", "group-1", state); - - // Should create a new tab and add it to the group - expect(result.tabs.length).toBe(4); - - // Find the new tab - const newTab = result.tabs.find( - (t) => t.id !== "group-1" && t.id !== "child-1" && t.id !== "child-2", - ); - expect(newTab).toBeDefined(); - expect(newTab?.parentId).toBe("group-1"); - - // Group layout should be updated to include new tab - const updatedGroup = result.tabs.find((t) => t.id === "group-1"); - if (updatedGroup?.type === TabType.Group && newTab) { - expect(updatedGroup.layout).toEqual({ - direction: "row", - first: { - direction: "row", - first: "child-1", - second: "child-2", - splitPercentage: 50, - }, - second: newTab.id, - splitPercentage: 50, - }); - } - - // Group should remain active - expect(result.activeTabIds[workspaceId]).toBe("group-1"); - }); - - test("dragging child into another child of same group creates new tab", () => { - const groupTab: Tab = { - id: "group-1", - title: "Group", - workspaceId, - type: TabType.Group, - layout: { - direction: "row", - first: "child-1", - second: "child-2", - splitPercentage: 50, - }, - }; - - const child1: Tab = { - id: "child-1", - title: "Child 1", - workspaceId, - type: TabType.Single, - parentId: "group-1", - }; - - const child2: Tab = { - id: "child-2", - title: "Child 2", - workspaceId, - type: TabType.Single, - parentId: "group-1", - }; - - const state = { - tabs: [groupTab, child1, child2], - activeTabIds: { [workspaceId]: "group-1" }, - tabHistoryStacks: { [workspaceId]: [] }, - }; - - // Drag child-1 onto child-2 (both in same group) - const result = handleDragTabToTab("child-1", "child-2", state); - - // Should create a new tab and add it to the group - expect(result.tabs.length).toBe(4); - - // Find the new tab - const newTab = result.tabs.find( - (t) => t.id !== "group-1" && t.id !== "child-1" && t.id !== "child-2", - ); - expect(newTab).toBeDefined(); - expect(newTab?.parentId).toBe("group-1"); - - // Group layout should be updated to include new tab - const updatedGroup = result.tabs.find((t) => t.id === "group-1"); - if (updatedGroup?.type === TabType.Group && newTab) { - expect(updatedGroup.layout).toEqual({ - direction: "row", - first: { - direction: "row", - first: "child-1", - second: "child-2", - splitPercentage: 50, - }, - second: newTab.id, - splitPercentage: 50, - }); - } - - // Group should remain active - expect(result.activeTabIds[workspaceId]).toBe("group-1"); - }); - - test("dragging last child from group to another tab removes the group", () => { - const groupTab: Tab = { - id: "group-1", - title: "Group", - workspaceId, - type: TabType.Group, - layout: "child-1", - }; - - const child1: Tab = { - id: "child-1", - title: "Child 1", - workspaceId, - type: TabType.Single, - parentId: "group-1", - }; - - const singleTab: Tab = { - id: "tab-2", - title: "Tab 2", - workspaceId, - type: TabType.Single, - }; - - const state = { - tabs: [groupTab, child1, singleTab], - activeTabIds: { [workspaceId]: "group-1" }, - tabHistoryStacks: { [workspaceId]: [] }, - }; - - // Drag the only child from group-1 to tab-2 - const result = handleDragTabToTab("child-1", "tab-2", state); - - // The group should be removed - const groupStillExists = result.tabs.some((t) => t.id === "group-1"); - expect(groupStillExists).toBe(false); - - // Should have 3 tabs: child-1, tab-2, and a new group containing both - expect(result.tabs.length).toBe(3); - - // Verify child-1 and tab-2 are now in a new group - const newGroup = result.tabs.find((t) => t.type === TabType.Group); - expect(newGroup).toBeDefined(); - expect(newGroup?.id).not.toBe("group-1"); - - const updatedChild1 = result.tabs.find((t) => t.id === "child-1"); - const updatedTab2 = result.tabs.find((t) => t.id === "tab-2"); - - expect(updatedChild1?.parentId).toBe(newGroup?.id); - expect(updatedTab2?.parentId).toBe(newGroup?.id); - }); - - test("dragging last child from group to another group removes the source group", () => { - const group1: Tab = { - id: "group-1", - title: "Group 1", - workspaceId, - type: TabType.Group, - layout: "child-1", - }; - - const child1: Tab = { - id: "child-1", - title: "Child 1", - workspaceId, - type: TabType.Single, - parentId: "group-1", - }; - - const group2: Tab = { - id: "group-2", - title: "Group 2", - workspaceId, - type: TabType.Group, - layout: "child-2", - }; - - const child2: Tab = { - id: "child-2", - title: "Child 2", - workspaceId, - type: TabType.Single, - parentId: "group-2", - }; - - const state = { - tabs: [group1, child1, group2, child2], - activeTabIds: { [workspaceId]: "group-1" }, - tabHistoryStacks: { [workspaceId]: [] }, - }; - - // Drag the only child from group-1 to group-2 - const result = handleDragTabToTab("child-1", "group-2", state); - - // group-1 should be removed - const group1StillExists = result.tabs.some((t) => t.id === "group-1"); - expect(group1StillExists).toBe(false); - - // group-2 should still exist - const updatedGroup2 = result.tabs.find((t) => t.id === "group-2"); - expect(updatedGroup2).toBeDefined(); - - // group-2 should now contain both children - if (updatedGroup2?.type === TabType.Group) { - expect(updatedGroup2.layout).toEqual({ - direction: "row", - first: "child-2", - second: "child-1", - splitPercentage: 50, - }); - } - - // Verify child-1 now has group-2 as parent - const updatedChild1 = result.tabs.find((t) => t.id === "child-1"); - expect(updatedChild1?.parentId).toBe("group-2"); - - // Should have 3 tabs total: group-2, child-1, child-2 - expect(result.tabs.length).toBe(3); - }); - - test("dragging last child from nested layout to another tab removes the group", () => { - // Group with only one child in a simple layout - const groupTab: Tab = { - id: "group-1", - title: "Group", - workspaceId, - type: TabType.Group, - layout: "child-1", - }; - - const child1: Tab = { - id: "child-1", - title: "Child 1", - workspaceId, - type: TabType.Single, - parentId: "group-1", - }; - - const targetTab: Tab = { - id: "target-tab", - title: "Target", - workspaceId, - type: TabType.Single, - }; - - const state = { - tabs: [groupTab, child1, targetTab], - activeTabIds: { [workspaceId]: "group-1" }, - tabHistoryStacks: { [workspaceId]: [] }, - }; - - const result = handleDragTabToTab("child-1", "target-tab", state); - - // group-1 should be removed - expect(result.tabs.some((t) => t.id === "group-1")).toBe(false); - - // Should create a new group with child-1 and target-tab - const newGroup = result.tabs.find( - (t) => t.type === TabType.Group && t.id !== "group-1", - ); - expect(newGroup).toBeDefined(); - }); -}); diff --git a/apps/desktop/src/renderer/stores/tabs/drag-logic.ts b/apps/desktop/src/renderer/stores/tabs/drag-logic.ts deleted file mode 100644 index f1fa2739525..00000000000 --- a/apps/desktop/src/renderer/stores/tabs/drag-logic.ts +++ /dev/null @@ -1,467 +0,0 @@ -import type { MosaicNode } from "react-mosaic-component"; -import { type Tab, type TabGroup, TabType } from "./types"; -import { createNewTab } from "./utils"; - -export interface DragTabToTabResult { - tabs: Tab[]; - activeTabIds: Record; - tabHistoryStacks: Record; -} - -/** - * Removes a tab ID from a mosaic layout tree - * Returns null if the layout becomes empty after removal - */ -export const removeTabFromLayout = ( - layout: MosaicNode | null, - tabIdToRemove: string, -): MosaicNode | null => { - if (!layout) return null; - - // If layout is a leaf node (single tab ID) - if (typeof layout === "string") { - return layout === tabIdToRemove ? null : layout; - } - - // Recursively remove from both branches - const newFirst = removeTabFromLayout(layout.first, tabIdToRemove); - const newSecond = removeTabFromLayout(layout.second, tabIdToRemove); - - // If both branches are gone, return null - if (!newFirst && !newSecond) return null; - - // If one branch is gone, return the other - if (!newFirst) return newSecond; - if (!newSecond) return newFirst; - - // Both branches still exist, return updated layout - return { - ...layout, - first: newFirst, - second: newSecond, - }; -}; - -/** - * Validates layout against valid tab IDs and removes any invalid references - */ -export const cleanLayout = ( - layout: MosaicNode | null, - validTabIds: Set, -): MosaicNode | null => { - if (!layout) return null; - - if (typeof layout === "string") { - return validTabIds.has(layout) ? layout : null; - } - - const newFirst = cleanLayout(layout.first, validTabIds); - const newSecond = cleanLayout(layout.second, validTabIds); - - if (!newFirst && !newSecond) return null; - if (!newFirst) return newSecond; - if (!newSecond) return newFirst; - - // If children are identical references, return original layout to avoid churn - if (newFirst === layout.first && newSecond === layout.second) { - return layout; - } - - return { - ...layout, - first: newFirst, - second: newSecond, - }; -}; - -const removeFromOldParent = ( - tabs: Tab[], - tabId: string, - oldParentId: string, -): Tab[] => { - return tabs - .map((tab) => { - if (tab.id === oldParentId && tab.type === TabType.Group) { - const updatedLayout = removeTabFromLayout(tab.layout, tabId); - - return { - ...tab, - layout: updatedLayout, - }; - } - return tab; - }) - .filter((tab) => { - // Remove the parent group if it no longer has any children - if (tab.id === oldParentId && tab.type === TabType.Group) { - // Check if any tabs still have this group as their parent - const hasChildren = tabs.some( - (t) => t.parentId === oldParentId && t.id !== tabId, - ); - return hasChildren; - } - return true; - }); -}; - -const addToParentGroup = ( - parentGroup: TabGroup, - childTabId: string, -): TabGroup => { - const newLayout = - parentGroup.layout === null - ? childTabId - : { - direction: "row" as const, - first: parentGroup.layout, - second: childTabId, - splitPercentage: 50, - }; - - return { - ...parentGroup, - layout: newLayout, - }; -}; - -export const handleDragTabToTab = ( - draggedTabId: string, - targetTabId: string, - state: { - tabs: Tab[]; - activeTabIds: Record; - tabHistoryStacks: Record; - }, -): DragTabToTabResult => { - const draggedTab = state.tabs.find((tab) => tab.id === draggedTabId); - const targetTab = state.tabs.find((tab) => tab.id === targetTabId); - - if (!draggedTab || !targetTab) return state; - - const workspaceId = draggedTab.workspaceId; - const historyStack = state.tabHistoryStacks[workspaceId] || []; - - // Rule 1: Dragging tab into itself - creates new tab and makes a group - if (draggedTabId === targetTabId) { - const newTab = createNewTab(workspaceId, TabType.Single); - - // If dragged tab is a child tab, add new tab to its parent group - if (draggedTab.parentId) { - const parentGroup = state.tabs.find( - (tab) => tab.id === draggedTab.parentId && tab.type === TabType.Group, - ) as TabGroup | undefined; - - if (!parentGroup) return state; - - const updatedNewTab: Tab = { - ...newTab, - parentId: parentGroup.id, - }; - - const updatedParentGroup = addToParentGroup(parentGroup, newTab.id); - - const updatedTabs = state.tabs.map((tab) => { - if (tab.id === parentGroup.id) return updatedParentGroup; - return tab; - }); - - return { - ...state, - tabs: [...updatedTabs, updatedNewTab], - activeTabIds: { - ...state.activeTabIds, - [workspaceId]: parentGroup.id, - }, - }; - } - - // If dragged tab is a group, add new tab to the group - if (draggedTab.type === TabType.Group) { - const updatedNewTab: Tab = { - ...newTab, - parentId: draggedTab.id, - }; - - const updatedDraggedGroup = addToParentGroup(draggedTab, newTab.id); - - const updatedTabs = state.tabs.map((tab) => { - if (tab.id === draggedTab.id) return updatedDraggedGroup; - return tab; - }); - - return { - ...state, - tabs: [...updatedTabs, updatedNewTab], - activeTabIds: { - ...state.activeTabIds, - [workspaceId]: draggedTab.id, - }, - }; - } - - // If dragged tab is a single standalone tab, create a new group - const groupId = `tab-${Date.now()}-group`; - - const updatedTargetTab: Tab = { - ...draggedTab, - parentId: groupId, - }; - - const updatedNewTab: Tab = { - ...newTab, - parentId: groupId, - }; - - const newGroupTab: TabGroup = { - id: groupId, - title: `${draggedTab.title} + ${newTab.title}`, - workspaceId, - type: TabType.Group, - layout: { - direction: "row", - first: draggedTab.id, - second: newTab.id, - splitPercentage: 50, - }, - }; - - const workspaceTabs = state.tabs.filter( - (t) => t.workspaceId === workspaceId && !t.parentId, - ); - const targetIndex = workspaceTabs.findIndex((t) => t.id === targetTabId); - - const updatedTabs = state.tabs.map((tab) => { - if (tab.id === draggedTab.id) return updatedTargetTab; - return tab; - }); - - const workspaceTabsUpdated = updatedTabs.filter( - (t) => t.workspaceId === workspaceId && !t.parentId, - ); - const otherTabsUpdated = updatedTabs.filter( - (t) => t.workspaceId !== workspaceId || t.parentId, - ); - - workspaceTabsUpdated.splice(targetIndex, 0, newGroupTab); - - return { - ...state, - tabs: [...otherTabsUpdated, ...workspaceTabsUpdated, updatedNewTab], - activeTabIds: { - ...state.activeTabIds, - [workspaceId]: newGroupTab.id, - }, - }; - } - - // Rule 2: Dragging into a child tab - redirects to parent group since child tabs can't be drop targets - if (targetTab.parentId && draggedTab.type === TabType.Single) { - const parentGroup = state.tabs.find( - (tab) => tab.id === targetTab.parentId && tab.type === TabType.Group, - ) as TabGroup | undefined; - - if (!parentGroup) return state; - - // If dragging a child tab into another child tab of the same group, create a new tab - if (draggedTab.parentId === parentGroup.id) { - const newTab = createNewTab(workspaceId, TabType.Single); - - const updatedNewTab: Tab = { - ...newTab, - parentId: parentGroup.id, - }; - - const updatedParentGroup = addToParentGroup(parentGroup, newTab.id); - - const updatedTabs = state.tabs.map((tab) => { - if (tab.id === parentGroup.id) return updatedParentGroup; - return tab; - }); - - return { - ...state, - tabs: [...updatedTabs, updatedNewTab], - activeTabIds: { - ...state.activeTabIds, - [workspaceId]: parentGroup.id, - }, - }; - } - - const updatedDraggedTab: Tab = { - ...draggedTab, - parentId: parentGroup.id, - }; - - const updatedParentGroup = addToParentGroup(parentGroup, draggedTabId); - - let updatedTabs = state.tabs.map((tab) => { - if (tab.id === parentGroup.id) return updatedParentGroup; - if (tab.id === draggedTabId) return updatedDraggedTab; - return tab; - }); - - if (draggedTab.parentId) { - updatedTabs = removeFromOldParent( - updatedTabs, - draggedTabId, - draggedTab.parentId, - ); - } - - return { - ...state, - tabs: updatedTabs, - activeTabIds: { - ...state.activeTabIds, - [workspaceId]: parentGroup.id, - }, - tabHistoryStacks: { - ...state.tabHistoryStacks, - [workspaceId]: historyStack.filter((id) => id !== draggedTabId), - }, - }; - } - - // Rule 3: Dragging into a group tab - adds tab to existing split view group - if (targetTab.type === TabType.Group && draggedTab.type === TabType.Single) { - // If dragging a tab from the same group, create a new tab and add to the group - if (draggedTab.parentId === targetTabId) { - const newTab = createNewTab(workspaceId, TabType.Single); - - const updatedNewTab: Tab = { - ...newTab, - parentId: targetTabId, - }; - - const updatedTargetTab = addToParentGroup(targetTab, newTab.id); - - const updatedTabs = state.tabs.map((tab) => { - if (tab.id === targetTabId) return updatedTargetTab; - return tab; - }); - - return { - ...state, - tabs: [...updatedTabs, updatedNewTab], - activeTabIds: { - ...state.activeTabIds, - [workspaceId]: targetTabId, - }, - }; - } - - const updatedDraggedTab: Tab = { - ...draggedTab, - parentId: targetTabId, - }; - - const updatedTargetTab = addToParentGroup(targetTab, draggedTabId); - - let updatedTabs = state.tabs.map((tab) => { - if (tab.id === targetTabId) return updatedTargetTab; - if (tab.id === draggedTabId) return updatedDraggedTab; - return tab; - }); - - if (draggedTab.parentId) { - updatedTabs = removeFromOldParent( - updatedTabs, - draggedTabId, - draggedTab.parentId, - ); - } - - return { - ...state, - tabs: updatedTabs, - activeTabIds: { - ...state.activeTabIds, - [workspaceId]: targetTabId, - }, - tabHistoryStacks: { - ...state.tabHistoryStacks, - [workspaceId]: historyStack.filter((id) => id !== draggedTabId), - }, - }; - } - - // Rule 4: Dragging single tab into another single tab - creates new group container for split view - if (targetTab.type === TabType.Single && draggedTab.type === TabType.Single) { - const groupId = `tab-${Date.now()}-group`; - - // Keep original tab IDs stable - just update their parentId - const updatedTargetTab: Tab = { - ...targetTab, - parentId: groupId, - }; - - const updatedDraggedTab: Tab = { - ...draggedTab, - parentId: groupId, - }; - - const newGroupTab: TabGroup = { - id: groupId, - title: `${targetTab.title} + ${draggedTab.title}`, - workspaceId, - type: TabType.Group, - layout: { - direction: "row", - first: targetTab.id, // Use original ID, not new one - second: draggedTab.id, // Use original ID, not new one - splitPercentage: 50, - }, - }; - - // Find target tab's index in workspace to insert group at that position - const workspaceTabs = state.tabs.filter( - (t) => t.workspaceId === workspaceId && !t.parentId, - ); - const targetIndex = workspaceTabs.findIndex((t) => t.id === targetTabId); - - // Update existing tabs to set parentId - let updatedTabs = state.tabs.map((tab) => { - if (tab.id === targetTab.id) return updatedTargetTab; - if (tab.id === draggedTab.id) return updatedDraggedTab; - return tab; - }); - - // If dragged tab had an old parent, remove it from that parent (and potentially remove the parent group) - if (draggedTab.parentId) { - updatedTabs = removeFromOldParent( - updatedTabs, - draggedTabId, - draggedTab.parentId, - ); - } - - // Filter to get workspace tabs (excluding child tabs) - const workspaceTabsUpdated = updatedTabs.filter( - (t) => t.workspaceId === workspaceId && !t.parentId, - ); - const otherTabsUpdated = updatedTabs.filter( - (t) => t.workspaceId !== workspaceId || t.parentId, - ); - - // Insert the new group at the target's original index - workspaceTabsUpdated.splice(targetIndex, 0, newGroupTab); - - return { - ...state, - tabs: [...otherTabsUpdated, ...workspaceTabsUpdated], - activeTabIds: { - ...state.activeTabIds, - [workspaceId]: newGroupTab.id, - }, - tabHistoryStacks: { - ...state.tabHistoryStacks, - [workspaceId]: historyStack.filter( - (id) => id !== draggedTabId && id !== targetTabId, - ), - }, - }; - } - - return state; -};