From 41585e4a74fae1c64aa47d4324233b6c313ba329 Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 20 Nov 2025 13:15:28 -0800 Subject: [PATCH 1/3] WIP --- apps/desktop/package.json | 1 + apps/desktop/src/lib/trpc/routers/index.ts | 4 + apps/desktop/src/lib/trpc/routers/projects.ts | 109 +++++++++ .../src/lib/trpc/routers/workspaces.ts | 219 ++++++++++++++++++ apps/desktop/src/main/index.ts | 4 + apps/desktop/src/main/lib/db/index.ts | 67 ++++++ apps/desktop/src/main/lib/db/schemas.ts | 46 ++++ .../WorkspaceTabs/AddWorkspaceButton.tsx | 18 +- .../TopBar/WorkspaceTabs/WorkspaceItem.tsx | 29 ++- .../components/TopBar/WorkspaceTabs/index.tsx | 8 +- .../WorkspaceView/NewWorkspaceView.tsx | 168 ++++++++------ .../RecentSection/RecentProjectItem.tsx | 70 ++++++ .../RecentSection/RecentSection.tsx | 38 +++ .../components/RecentSection/index.ts | 2 + .../components/StartSection/StartSection.tsx | 26 +++ .../components/StartSection/index.ts | 1 + .../NewWorkspaceView/components/index.ts | 2 + .../main/components/WorkspaceView/index.tsx | 12 +- apps/desktop/src/shared/types.ts | 7 + bun.lock | 1 + 20 files changed, 747 insertions(+), 85 deletions(-) create mode 100644 apps/desktop/src/lib/trpc/routers/projects.ts create mode 100644 apps/desktop/src/lib/trpc/routers/workspaces.ts create mode 100644 apps/desktop/src/main/lib/db/index.ts create mode 100644 apps/desktop/src/main/lib/db/schemas.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/RecentSection/RecentProjectItem.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/RecentSection/RecentSection.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/RecentSection/index.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/StartSection/StartSection.tsx create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/StartSection/index.ts create mode 100644 apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/index.ts diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 58b665ec379..372dbdfdfcc 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -63,6 +63,7 @@ "superjson": "^2.2.5", "tailwind-merge": "^2.6.0", "trpc-electron": "^0.1.2", + "zod": "^4.1.12", "zustand": "^5.0.8" }, "devDependencies": { diff --git a/apps/desktop/src/lib/trpc/routers/index.ts b/apps/desktop/src/lib/trpc/routers/index.ts index a2d8bda9445..263d30d3cf6 100644 --- a/apps/desktop/src/lib/trpc/routers/index.ts +++ b/apps/desktop/src/lib/trpc/routers/index.ts @@ -1,6 +1,8 @@ import type { BrowserWindow } from "electron"; import { router } from ".."; import { createWindowRouter } from "./window"; +import { createProjectsRouter } from "./projects"; +import { createWorkspacesRouter } from "./workspaces"; /** * Main application router @@ -9,6 +11,8 @@ import { createWindowRouter } from "./window"; export const createAppRouter = (window: BrowserWindow) => { return router({ window: createWindowRouter(window), + projects: createProjectsRouter(window), + workspaces: createWorkspacesRouter(), }); }; diff --git a/apps/desktop/src/lib/trpc/routers/projects.ts b/apps/desktop/src/lib/trpc/routers/projects.ts new file mode 100644 index 00000000000..b611d8a0544 --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/projects.ts @@ -0,0 +1,109 @@ +import { dialog } from "electron"; +import type { BrowserWindow } from "electron"; +import { basename } from "node:path"; +import { z } from "zod"; +import { publicProcedure, router } from ".."; +import { readDb, writeDb } from "../../../main/lib/db"; +import type { RecentProject } from "../../../main/lib/db/schemas"; + +/** + * Projects router + * Handles project selection, recents management, and workspace creation + */ +export const createProjectsRouter = (window: BrowserWindow) => { + return router({ + /** + * Open a new project via folder picker + * Adds to recents and returns path for UI to handle + */ + openProject: publicProcedure.mutation(async () => { + const result = await dialog.showOpenDialog(window, { + properties: ["openDirectory"], + title: "Open Project", + }); + + if (result.canceled || result.filePaths.length === 0) { + return { success: false as const }; + } + + const path = result.filePaths[0]; + const name = basename(path); + const timestamp = Date.now(); + + // Add to recents (or update if exists) + await writeDb((data) => { + const existingIndex = data.recentProjects.findIndex( + (p) => p.path === path, + ); + if (existingIndex !== -1) { + data.recentProjects[existingIndex].lastOpened = timestamp; + } else { + data.recentProjects.push({ + path, + name, + lastOpened: timestamp, + }); + } + }); + + return { + success: true as const, + path, + name, + }; + }), + + /** + * Open a recent project + * Updates timestamp and returns path for UI to handle + */ + openRecent: publicProcedure + .input(z.object({ path: z.string() })) + .mutation(async ({ input }) => { + const { path } = input; + const name = basename(path); + const timestamp = Date.now(); + + // Update recent project timestamp + await writeDb((data) => { + const recent = data.recentProjects.find((p) => p.path === path); + if (recent) { + recent.lastOpened = timestamp; + } + }); + + return { + success: true as const, + path, + name, + }; + }), + + /** + * Get all recent projects sorted by last opened + */ + getRecents: publicProcedure.query((): RecentProject[] => { + const db = readDb(); + return db.recentProjects + .slice() + .sort((a, b) => b.lastOpened - a.lastOpened); + }), + + /** + * Remove a project from recents + */ + removeRecent: publicProcedure + .input(z.object({ path: z.string() })) + .mutation(async ({ input }) => { + await writeDb((data) => { + data.recentProjects = data.recentProjects.filter( + (p) => p.path !== input.path, + ); + }); + + return { success: true }; + }), + }); +}; + +export type ProjectsRouter = ReturnType; diff --git a/apps/desktop/src/lib/trpc/routers/workspaces.ts b/apps/desktop/src/lib/trpc/routers/workspaces.ts new file mode 100644 index 00000000000..7647e35227a --- /dev/null +++ b/apps/desktop/src/lib/trpc/routers/workspaces.ts @@ -0,0 +1,219 @@ +import { z } from "zod"; +import { publicProcedure, router } from ".."; +import { readDb, writeDb } from "../../../main/lib/db"; + +/** + * Workspaces router + * Handles workspace CRUD operations + */ +export const createWorkspacesRouter = () => { + return router({ + /** + * Create a new workspace + */ + create: publicProcedure + .input( + z.object({ + name: z.string(), + path: z.string().nullable().optional(), + }), + ) + .mutation(async ({ input }) => { + const timestamp = Date.now(); + const db = readDb(); + + // Set order to be at the end of the list + const maxOrder = db.workspaces.length > 0 + ? Math.max(...db.workspaces.map((w) => w.order)) + : -1; + + const workspace = { + id: `workspace-${timestamp}-${Math.random().toString(36).substring(2, 11)}`, + name: input.name, + path: input.path ?? null, + order: maxOrder + 1, + createdAt: timestamp, + lastOpened: timestamp, + }; + + await writeDb((data) => { + data.workspaces.push(workspace); + data.settings.lastActiveWorkspaceId = workspace.id; + }); + + return workspace; + }), + + /** + * Get a workspace by ID + */ + get: publicProcedure + .input(z.object({ id: z.string() })) + .query(({ input }) => { + const db = readDb(); + const workspace = db.workspaces.find((w) => w.id === input.id); + return workspace || null; + }), + + /** + * Get all workspaces sorted by order + */ + getAll: publicProcedure.query(() => { + const db = readDb(); + return db.workspaces + .slice() + .sort((a, b) => a.order - b.order); + }), + + /** + * Get the last active workspace + */ + getActive: publicProcedure.query(() => { + const db = readDb(); + const { lastActiveWorkspaceId } = db.settings; + + if (!lastActiveWorkspaceId) { + return null; + } + + return db.workspaces.find((w) => w.id === lastActiveWorkspaceId) || null; + }), + + /** + * Update a workspace + * Supports partial updates to workspace properties + */ + update: publicProcedure + .input( + z.object({ + id: z.string(), + patch: z.object({ + name: z.string().optional(), + path: z.string().nullable().optional(), + }), + }), + ) + .mutation(async ({ input }) => { + await writeDb((data) => { + const workspace = data.workspaces.find((w) => w.id === input.id); + if (!workspace) { + throw new Error(`Workspace ${input.id} not found`); + } + + // Apply patches + if (input.patch.name !== undefined) { + workspace.name = input.patch.name; + } + if (input.patch.path !== undefined) { + workspace.path = input.patch.path; + } + + // Update last opened + workspace.lastOpened = Date.now(); + }); + + return { success: true }; + }), + + /** + * Delete a workspace + * Also removes from recents if no other workspace uses that path + */ + delete: publicProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ input }) => { + const db = readDb(); + const workspace = db.workspaces.find((w) => w.id === input.id); + + if (!workspace) { + return { success: false, error: "Workspace not found" }; + } + + const workspacePath = workspace.path; + + await writeDb((data) => { + // Remove workspace + data.workspaces = data.workspaces.filter((w) => w.id !== input.id); + + // Check if any other workspace uses this path + const otherWorkspaceWithSamePath = data.workspaces.some( + (w) => w.path === workspacePath, + ); + + // If no other workspace uses this path, remove from recents + if (!otherWorkspaceWithSamePath) { + data.recentProjects = data.recentProjects.filter( + (p) => p.path !== workspacePath, + ); + } + + // Update last active workspace if needed + if (data.settings.lastActiveWorkspaceId === input.id) { + // Set to the most recently opened workspace, if any + const sorted = data.workspaces + .slice() + .sort((a, b) => b.lastOpened - a.lastOpened); + data.settings.lastActiveWorkspaceId = sorted[0]?.id || undefined; + } + }); + + return { success: true }; + }), + + /** + * Set active workspace + */ + setActive: publicProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ input }) => { + await writeDb((data) => { + 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.lastOpened = Date.now(); + }); + + return { success: true }; + }), + + /** + * Reorder workspaces + */ + reorder: publicProcedure + .input( + z.object({ + fromIndex: z.number(), + toIndex: z.number(), + }), + ) + .mutation(async ({ input }) => { + await writeDb((data) => { + const { fromIndex, toIndex } = input; + + // Get all workspaces sorted by order + const workspaces = data.workspaces + .slice() + .sort((a, b) => a.order - b.order); + + // Move workspace from fromIndex to toIndex + const [removed] = workspaces.splice(fromIndex, 1); + workspaces.splice(toIndex, 0, removed); + + // Update order fields to reflect new positions + workspaces.forEach((workspace, index) => { + const ws = data.workspaces.find((w) => w.id === workspace.id); + if (ws) { + ws.order = index; + } + }); + }); + + return { success: true }; + }), + }); +}; + +export type WorkspacesRouter = ReturnType; diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index bf0ccab8a89..f5758b762a0 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -3,6 +3,7 @@ import { app } from "electron"; import { makeAppSetup } from "lib/electron-app/factories/app/setup"; import { registerStorageHandlers } from "./lib/storage-ipcs"; import { MainWindow } from "./windows/main"; +import { initDatabase } from "./lib/db"; // Protocol scheme for deep linking const PROTOCOL_SCHEME = "superset"; @@ -31,6 +32,9 @@ registerStorageHandlers(); (async () => { await app.whenReady(); + // Initialize database before creating windows + await initDatabase(); + await makeAppSetup(() => MainWindow()); // Stop all periodic rescans when app is quitting diff --git a/apps/desktop/src/main/lib/db/index.ts b/apps/desktop/src/main/lib/db/index.ts new file mode 100644 index 00000000000..c7d16f9ba64 --- /dev/null +++ b/apps/desktop/src/main/lib/db/index.ts @@ -0,0 +1,67 @@ +/** + * Database initialization and access + * Uses lowdb for local JSON file storage + */ + +import { JSONFilePreset } from "lowdb/node"; +import { join } from "node:path"; +import { app } from "electron"; +import type { Database } from "./schemas"; +import { defaultDatabase } from "./schemas"; + +let db: Awaited>> | null = null; + +/** + * Get database file path + * Stored in ~/.superset/db.json + */ +function getDbPath(): string { + const userDataPath = app.getPath("userData"); + return join(userDataPath, "db.json"); +} + +/** + * Initialize the database + * Should be called once when the app starts + */ +export async function initDatabase(): Promise { + if (db) { + return; + } + + const dbPath = getDbPath(); + db = await JSONFilePreset(dbPath, defaultDatabase); + + console.log(`Database initialized at: ${dbPath}`); +} + +/** + * Get the database instance + * Throws if database hasn't been initialized + */ +export function getDb(): Awaited>> { + if (!db) { + throw new Error( + "Database not initialized. Call initDatabase() first.", + ); + } + return db; +} + +/** + * Helper to read database data + */ +export function readDb(): Database { + const database = getDb(); + return database.data; +} + +/** + * Helper to write database data + * Automatically saves to file + */ +export async function writeDb(updater: (data: Database) => void): Promise { + const database = getDb(); + updater(database.data); + await database.write(); +} diff --git a/apps/desktop/src/main/lib/db/schemas.ts b/apps/desktop/src/main/lib/db/schemas.ts new file mode 100644 index 00000000000..5a69cb4deb6 --- /dev/null +++ b/apps/desktop/src/main/lib/db/schemas.ts @@ -0,0 +1,46 @@ +/** + * Database schemas for local-first storage + * These types define the structure of data stored in lowdb + */ + +export interface RecentProject { + path: string; + name: string; + lastOpened: number; +} + +export interface Tab { + id: string; + title: string; + terminalId?: string; + type: "single" | "group"; + createdAt: number; +} + +export interface Workspace { + id: string; + path: string | null; // null for new workspaces that haven't opened a project yet + name: string; + order: number; // Explicit order in the workspace tabs (0 = first, 1 = second, etc.) + createdAt: number; + lastOpened: number; +} + +export interface Settings { + lastActiveWorkspaceId?: string; +} + +export interface Database { + workspaces: Workspace[]; + recentProjects: RecentProject[]; + settings: Settings; +} + +/** + * Default database state + */ +export const defaultDatabase: Database = { + workspaces: [], + recentProjects: [], + settings: {}, +}; diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/AddWorkspaceButton.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/AddWorkspaceButton.tsx index 64713fbded0..e9711731440 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/AddWorkspaceButton.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/AddWorkspaceButton.tsx @@ -1,14 +1,26 @@ import { Button } from "@superset/ui/button"; -import { useWorkspacesStore } from "renderer/stores/workspaces"; +import { trpc } from "renderer/lib/trpc"; export function AddWorkspaceButton() { - const { addWorkspace } = useWorkspacesStore(); + const utils = trpc.useUtils(); + const createWorkspace = trpc.workspaces.create.useMutation({ + onSuccess: async () => { + // Invalidate all workspace queries + await utils.workspaces.invalidate(); + }, + }); + + const handleAddWorkspace = () => { + createWorkspace.mutate({ + name: "New Workspace", + }); + }; return ( - - - +
+ {/* Left column - Start and Recent sections */} +
+
+

+ Welcome to Superset +

+

+ Open a project to get started +

+
+ +
+ + + +
+
+ + {/* Right column - Placeholder for future content */} +
+
+

+ Quick actions and walkthroughs will appear here +

diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/RecentSection/RecentProjectItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/RecentSection/RecentProjectItem.tsx new file mode 100644 index 00000000000..dcbcc69aac8 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/RecentSection/RecentProjectItem.tsx @@ -0,0 +1,70 @@ +import { Folder, X } from "lucide-react"; +import { Button } from "@superset/ui/button"; +import type { RecentProject } from "shared/types"; + +interface RecentProjectItemProps { + project: RecentProject; + onOpen: (path: string) => void; + onRemove: (path: string) => void; +} + +function formatTimestamp(timestamp: number): string { + const now = Date.now(); + const diff = now - timestamp; + + const seconds = Math.floor(diff / 1000); + const minutes = Math.floor(seconds / 60); + const hours = Math.floor(minutes / 60); + const days = Math.floor(hours / 24); + + if (days > 0) { + return days === 1 ? "Yesterday" : `${days} days ago`; + } + if (hours > 0) { + return `${hours} hour${hours > 1 ? "s" : ""} ago`; + } + if (minutes > 0) { + return `${minutes} minute${minutes > 1 ? "s" : ""} ago`; + } + return "Just now"; +} + +export function RecentProjectItem({ + project, + onOpen, + onRemove, +}: RecentProjectItemProps) { + return ( + + + + ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/RecentSection/RecentSection.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/RecentSection/RecentSection.tsx new file mode 100644 index 00000000000..9a0f2e7212e --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/RecentSection/RecentSection.tsx @@ -0,0 +1,38 @@ +import { ScrollArea } from "@superset/ui/scroll-area"; + +import { RecentProjectItem } from "./RecentProjectItem"; +import type { RecentProject } from "shared/types"; + +interface RecentSectionProps { + recents: RecentProject[]; + onOpenRecent: (path: string) => void; + onRemoveRecent: (path: string) => void; +} + +export function RecentSection({ + recents, + onOpenRecent, + onRemoveRecent, +}: RecentSectionProps) { + if (recents.length === 0) { + return null; + } + + return ( +
+

Recent

+ +
+ {recents.map((project) => ( + + ))} +
+
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/RecentSection/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/RecentSection/index.ts new file mode 100644 index 00000000000..7d5fdc9d9e6 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/RecentSection/index.ts @@ -0,0 +1,2 @@ +export { RecentSection } from "./RecentSection"; +export { RecentProjectItem } from "./RecentProjectItem"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/StartSection/StartSection.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/StartSection/StartSection.tsx new file mode 100644 index 00000000000..ac1977c9413 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/StartSection/StartSection.tsx @@ -0,0 +1,26 @@ +import { Button } from "@superset/ui/button"; +import { FolderOpen } from "lucide-react"; + +interface StartSectionProps { + onOpenProject: () => void; + isLoading?: boolean; +} + +export function StartSection({ onOpenProject, isLoading }: StartSectionProps) { + return ( +
+

Start

+
+ +
+
+ ); +} diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/StartSection/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/StartSection/index.ts new file mode 100644 index 00000000000..54f382cc712 --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/StartSection/index.ts @@ -0,0 +1 @@ +export { StartSection } from "./StartSection"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/index.ts b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/index.ts new file mode 100644 index 00000000000..5def4cd32df --- /dev/null +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/index.ts @@ -0,0 +1,2 @@ +export { StartSection } from "./StartSection"; +export { RecentSection } from "./RecentSection"; diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx index ce5bfd990e6..c0c32979953 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/index.tsx @@ -1,15 +1,15 @@ -import { useWorkspacesStore } from "renderer/stores/workspaces"; +import { trpc } from "renderer/lib/trpc"; import { ContentView } from "./ContentView"; import { NewWorkspaceView } from "./NewWorkspaceView"; import { Sidebar } from "./Sidebar"; export function WorkspaceView() { - const { workspaces, activeWorkspaceId } = useWorkspacesStore(); - const activeWorkspace = workspaces.find( - (workspace) => workspace.id === activeWorkspaceId, - ); + const { data: activeWorkspace } = trpc.workspaces.getActive.useQuery(); + + // If no workspace or workspace has no path, show new workspace view + const isNew = !activeWorkspace || activeWorkspace.path === null; - if (activeWorkspace?.isNew) { + if (isNew) { return (
diff --git a/apps/desktop/src/shared/types.ts b/apps/desktop/src/shared/types.ts index 95eef46b1fb..9c6bf26cf09 100644 --- a/apps/desktop/src/shared/types.ts +++ b/apps/desktop/src/shared/types.ts @@ -145,3 +145,10 @@ export interface DetectedPort { terminalId: string; detectedAt: string; } + +// Database types for local-first storage +export interface RecentProject { + path: string; + name: string; + lastOpened: number; +} diff --git a/bun.lock b/bun.lock index 75232e4abf3..41e8ed159fb 100644 --- a/bun.lock +++ b/bun.lock @@ -112,6 +112,7 @@ "superjson": "^2.2.5", "tailwind-merge": "^2.6.0", "trpc-electron": "^0.1.2", + "zod": "^4.1.12", "zustand": "^5.0.8", }, "devDependencies": { From 1703532a0c6ec54ee5dc58603af539a954343d4e Mon Sep 17 00:00:00 2001 From: Satya Patel Date: Thu, 20 Nov 2025 13:27:56 -0800 Subject: [PATCH 2/3] WIP - about to organize trpc mutations --- apps/desktop/package.json | 1 + apps/desktop/src/lib/trpc/routers/projects.ts | 36 +++------- .../src/lib/trpc/routers/workspaces.ts | 51 +++++++------- apps/desktop/src/main/index.ts | 6 +- apps/desktop/src/main/lib/db/index.ts | 69 ++++--------------- apps/desktop/src/main/lib/db/schemas.ts | 10 +-- .../RecentSection/RecentProjectItem.tsx | 2 +- apps/desktop/src/shared/types.ts | 2 +- bun.lock | 7 +- 9 files changed, 65 insertions(+), 119 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 372dbdfdfcc..efc3e8bf960 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -49,6 +49,7 @@ "framer-motion": "^12.23.24", "http-proxy": "^1.18.1", "lowdb": "^7.0.1", + "nanoid": "^5.1.6", "node-pty": "1.1.0-beta30", "react": "^19.1.1", "react-arborist": "^3.4.3", diff --git a/apps/desktop/src/lib/trpc/routers/projects.ts b/apps/desktop/src/lib/trpc/routers/projects.ts index b611d8a0544..29a6f77b92f 100644 --- a/apps/desktop/src/lib/trpc/routers/projects.ts +++ b/apps/desktop/src/lib/trpc/routers/projects.ts @@ -3,7 +3,7 @@ import type { BrowserWindow } from "electron"; import { basename } from "node:path"; import { z } from "zod"; import { publicProcedure, router } from ".."; -import { readDb, writeDb } from "../../../main/lib/db"; +import { db } from "../../../main/lib/db"; import type { RecentProject } from "../../../main/lib/db/schemas"; /** @@ -28,20 +28,18 @@ export const createProjectsRouter = (window: BrowserWindow) => { const path = result.filePaths[0]; const name = basename(path); - const timestamp = Date.now(); - // Add to recents (or update if exists) - await writeDb((data) => { + await db.update((data) => { const existingIndex = data.recentProjects.findIndex( (p) => p.path === path, ); if (existingIndex !== -1) { - data.recentProjects[existingIndex].lastOpened = timestamp; + data.recentProjects[existingIndex].lastOpenedAt = Date.now(); } else { data.recentProjects.push({ path, name, - lastOpened: timestamp, + lastOpenedAt: Date.now(), }); } }); @@ -52,23 +50,16 @@ export const createProjectsRouter = (window: BrowserWindow) => { name, }; }), - - /** - * Open a recent project - * Updates timestamp and returns path for UI to handle - */ openRecent: publicProcedure .input(z.object({ path: z.string() })) .mutation(async ({ input }) => { const { path } = input; const name = basename(path); - const timestamp = Date.now(); - // Update recent project timestamp - await writeDb((data) => { + await db.update((data) => { const recent = data.recentProjects.find((p) => p.path === path); if (recent) { - recent.lastOpened = timestamp; + recent.lastOpenedAt = Date.now(); } }); @@ -78,24 +69,15 @@ export const createProjectsRouter = (window: BrowserWindow) => { name, }; }), - - /** - * Get all recent projects sorted by last opened - */ getRecents: publicProcedure.query((): RecentProject[] => { - const db = readDb(); - return db.recentProjects + return db.data.recentProjects .slice() - .sort((a, b) => b.lastOpened - a.lastOpened); + .sort((a, b) => b.lastOpenedAt - a.lastOpenedAt); }), - - /** - * Remove a project from recents - */ removeRecent: publicProcedure .input(z.object({ path: z.string() })) .mutation(async ({ input }) => { - await writeDb((data) => { + await db.update((data) => { data.recentProjects = data.recentProjects.filter( (p) => p.path !== input.path, ); diff --git a/apps/desktop/src/lib/trpc/routers/workspaces.ts b/apps/desktop/src/lib/trpc/routers/workspaces.ts index 7647e35227a..df3cdc3d481 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces.ts @@ -1,6 +1,7 @@ import { z } from "zod"; +import { nanoid } from "nanoid"; import { publicProcedure, router } from ".."; -import { readDb, writeDb } from "../../../main/lib/db"; +import { db } from "../../../main/lib/db"; /** * Workspaces router @@ -19,24 +20,22 @@ export const createWorkspacesRouter = () => { }), ) .mutation(async ({ input }) => { - const timestamp = Date.now(); - const db = readDb(); - // Set order to be at the end of the list - const maxOrder = db.workspaces.length > 0 - ? Math.max(...db.workspaces.map((w) => w.order)) + const maxOrder = db.data.workspaces.length > 0 + ? Math.max(...db.data.workspaces.map((w) => w.order)) : -1; const workspace = { - id: `workspace-${timestamp}-${Math.random().toString(36).substring(2, 11)}`, + id: nanoid(), name: input.name, path: input.path ?? null, order: maxOrder + 1, - createdAt: timestamp, - lastOpened: timestamp, + createdAt: Date.now(), + updatedAt: Date.now(), + lastOpenedAt: Date.now(), }; - await writeDb((data) => { + await db.update((data) => { data.workspaces.push(workspace); data.settings.lastActiveWorkspaceId = workspace.id; }); @@ -50,8 +49,7 @@ export const createWorkspacesRouter = () => { get: publicProcedure .input(z.object({ id: z.string() })) .query(({ input }) => { - const db = readDb(); - const workspace = db.workspaces.find((w) => w.id === input.id); + const workspace = db.data.workspaces.find((w) => w.id === input.id); return workspace || null; }), @@ -59,8 +57,7 @@ export const createWorkspacesRouter = () => { * Get all workspaces sorted by order */ getAll: publicProcedure.query(() => { - const db = readDb(); - return db.workspaces + return db.data.workspaces .slice() .sort((a, b) => a.order - b.order); }), @@ -69,14 +66,13 @@ export const createWorkspacesRouter = () => { * Get the last active workspace */ getActive: publicProcedure.query(() => { - const db = readDb(); - const { lastActiveWorkspaceId } = db.settings; + const { lastActiveWorkspaceId } = db.data.settings; if (!lastActiveWorkspaceId) { return null; } - return db.workspaces.find((w) => w.id === lastActiveWorkspaceId) || null; + return db.data.workspaces.find((w) => w.id === lastActiveWorkspaceId) || null; }), /** @@ -94,7 +90,7 @@ export const createWorkspacesRouter = () => { }), ) .mutation(async ({ input }) => { - await writeDb((data) => { + await db.update((data) => { const workspace = data.workspaces.find((w) => w.id === input.id); if (!workspace) { throw new Error(`Workspace ${input.id} not found`); @@ -108,8 +104,9 @@ export const createWorkspacesRouter = () => { workspace.path = input.patch.path; } - // Update last opened - workspace.lastOpened = Date.now(); + // Update timestamps + workspace.updatedAt = Date.now(); + workspace.lastOpenedAt = Date.now(); }); return { success: true }; @@ -122,8 +119,7 @@ export const createWorkspacesRouter = () => { delete: publicProcedure .input(z.object({ id: z.string() })) .mutation(async ({ input }) => { - const db = readDb(); - const workspace = db.workspaces.find((w) => w.id === input.id); + const workspace = db.data.workspaces.find((w) => w.id === input.id); if (!workspace) { return { success: false, error: "Workspace not found" }; @@ -131,7 +127,7 @@ export const createWorkspacesRouter = () => { const workspacePath = workspace.path; - await writeDb((data) => { + await db.update((data) => { // Remove workspace data.workspaces = data.workspaces.filter((w) => w.id !== input.id); @@ -152,7 +148,7 @@ export const createWorkspacesRouter = () => { // Set to the most recently opened workspace, if any const sorted = data.workspaces .slice() - .sort((a, b) => b.lastOpened - a.lastOpened); + .sort((a, b) => b.lastOpenedAt - a.lastOpenedAt); data.settings.lastActiveWorkspaceId = sorted[0]?.id || undefined; } }); @@ -166,14 +162,15 @@ export const createWorkspacesRouter = () => { setActive: publicProcedure .input(z.object({ id: z.string() })) .mutation(async ({ input }) => { - await writeDb((data) => { + await db.update((data) => { 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.lastOpened = Date.now(); + workspace.lastOpenedAt = Date.now(); + workspace.updatedAt = Date.now(); }); return { success: true }; @@ -190,7 +187,7 @@ export const createWorkspacesRouter = () => { }), ) .mutation(async ({ input }) => { - await writeDb((data) => { + await db.update((data) => { const { fromIndex, toIndex } = input; // Get all workspaces sorted by order diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index f5758b762a0..37fa9676e67 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -3,7 +3,7 @@ import { app } from "electron"; import { makeAppSetup } from "lib/electron-app/factories/app/setup"; import { registerStorageHandlers } from "./lib/storage-ipcs"; import { MainWindow } from "./windows/main"; -import { initDatabase } from "./lib/db"; +import { initDb } from "./lib/db"; // Protocol scheme for deep linking const PROTOCOL_SCHEME = "superset"; @@ -32,8 +32,8 @@ registerStorageHandlers(); (async () => { await app.whenReady(); - // Initialize database before creating windows - await initDatabase(); + // Initialize database + await initDb(); await makeAppSetup(() => MainWindow()); diff --git a/apps/desktop/src/main/lib/db/index.ts b/apps/desktop/src/main/lib/db/index.ts index c7d16f9ba64..a01bc8e1664 100644 --- a/apps/desktop/src/main/lib/db/index.ts +++ b/apps/desktop/src/main/lib/db/index.ts @@ -1,67 +1,26 @@ -/** - * Database initialization and access - * Uses lowdb for local JSON file storage - */ - import { JSONFilePreset } from "lowdb/node"; import { join } from "node:path"; import { app } from "electron"; import type { Database } from "./schemas"; import { defaultDatabase } from "./schemas"; -let db: Awaited>> | null = null; - -/** - * Get database file path - * Stored in ~/.superset/db.json - */ -function getDbPath(): string { - const userDataPath = app.getPath("userData"); - return join(userDataPath, "db.json"); -} +type DB = Awaited>>; -/** - * Initialize the database - * Should be called once when the app starts - */ -export async function initDatabase(): Promise { - if (db) { - return; - } +let _db: DB | null = null; - const dbPath = getDbPath(); - db = await JSONFilePreset(dbPath, defaultDatabase); +export async function initDb(): Promise { + if (_db) return; + const dbPath = join(app.getPath("userData"), "db.json"); + _db = await JSONFilePreset(dbPath, defaultDatabase); console.log(`Database initialized at: ${dbPath}`); } -/** - * Get the database instance - * Throws if database hasn't been initialized - */ -export function getDb(): Awaited>> { - if (!db) { - throw new Error( - "Database not initialized. Call initDatabase() first.", - ); - } - return db; -} - -/** - * Helper to read database data - */ -export function readDb(): Database { - const database = getDb(); - return database.data; -} - -/** - * Helper to write database data - * Automatically saves to file - */ -export async function writeDb(updater: (data: Database) => void): Promise { - const database = getDb(); - updater(database.data); - await database.write(); -} +export const db = new Proxy({} as DB, { + get(_target, prop) { + if (!_db) { + throw new Error("Database not initialized. Call initDb() first."); + } + return _db[prop as keyof DB]; + }, +}); diff --git a/apps/desktop/src/main/lib/db/schemas.ts b/apps/desktop/src/main/lib/db/schemas.ts index 5a69cb4deb6..8964461332a 100644 --- a/apps/desktop/src/main/lib/db/schemas.ts +++ b/apps/desktop/src/main/lib/db/schemas.ts @@ -6,24 +6,26 @@ export interface RecentProject { path: string; name: string; - lastOpened: number; + lastOpenedAt: number; } export interface Tab { - id: string; + id: string; // nanoid title: string; terminalId?: string; type: "single" | "group"; createdAt: number; + updatedAt: number; } export interface Workspace { - id: string; + id: string; // nanoid path: string | null; // null for new workspaces that haven't opened a project yet name: string; order: number; // Explicit order in the workspace tabs (0 = first, 1 = second, etc.) createdAt: number; - lastOpened: number; + updatedAt: number; + lastOpenedAt: number; } export interface Settings { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/RecentSection/RecentProjectItem.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/RecentSection/RecentProjectItem.tsx index dcbcc69aac8..646922d7be5 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/RecentSection/RecentProjectItem.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/NewWorkspaceView/components/RecentSection/RecentProjectItem.tsx @@ -51,7 +51,7 @@ export function RecentProjectItem({
- {formatTimestamp(project.lastOpened)} + {formatTimestamp(project.lastOpenedAt)}