diff --git a/apps/admin/package.json b/apps/admin/package.json index 45bf13da32b..d867de83398 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -25,6 +25,7 @@ "@trpc/server": "^11.7.1", "@trpc/tanstack-react-query": "^11.7.1", "date-fns": "^4.1.0", + "drizzle-orm": "0.45.1", "import-in-the-middle": "2.0.1", "next": "^16.0.10", "next-themes": "^0.4.6", diff --git a/apps/admin/src/proxy.ts b/apps/admin/src/proxy.ts index c3b5bfb88a6..087e49b2672 100644 --- a/apps/admin/src/proxy.ts +++ b/apps/admin/src/proxy.ts @@ -1,7 +1,8 @@ import { clerkMiddleware } from "@clerk/nextjs/server"; -import { db, eq } from "@superset/db"; +import { db } from "@superset/db/client"; import { users } from "@superset/db/schema"; import { COMPANY } from "@superset/shared/constants"; +import { eq } from "drizzle-orm"; import { NextResponse } from "next/server"; import { env } from "./env"; diff --git a/apps/desktop/electron-builder.ts b/apps/desktop/electron-builder.ts index 30624931388..6ade6f1e1f3 100644 --- a/apps/desktop/electron-builder.ts +++ b/apps/desktop/electron-builder.ts @@ -33,6 +33,10 @@ const config: Configuration = { // ASAR configuration for native modules and external resources asar: true, asarUnpack: [ + "**/node_modules/better-sqlite3/**/*", + // better-sqlite3 uses `bindings` to locate native modules - must be unpacked together + "**/node_modules/bindings/**/*", + "**/node_modules/file-uri-to-path/**/*", "**/node_modules/node-pty/**/*", // Sound files must be unpacked so external audio players (afplay, paplay, etc.) can access them "**/resources/sounds/**/*", @@ -46,9 +50,32 @@ const config: Configuration = { to: "resources", filter: ["**/*"], }, - // Native module that can't be bundled by Vite. + // Database migrations from local-db package (copied to dist/resources/migrations by vite) + { + from: "dist/resources/migrations", + to: "resources/migrations", + filter: ["**/*"], + }, + // Native modules that can't be bundled by Vite. // The copy:native-modules script replaces symlinks with real files // before building (required for Bun 1.3+ isolated installs). + { + from: "node_modules/better-sqlite3", + to: "node_modules/better-sqlite3", + filter: ["**/*"], + }, + // better-sqlite3 uses `bindings` package to locate its native .node file + { + from: "node_modules/bindings", + to: "node_modules/bindings", + filter: ["**/*"], + }, + // `bindings` requires `file-uri-to-path` for file:// URL handling + { + from: "node_modules/file-uri-to-path", + to: "node_modules/file-uri-to-path", + filter: ["**/*"], + }, { from: "node_modules/node-pty", to: "node_modules/node-pty", diff --git a/apps/desktop/electron.vite.config.ts b/apps/desktop/electron.vite.config.ts index be10b0fabf0..2a279c85357 100644 --- a/apps/desktop/electron.vite.config.ts +++ b/apps/desktop/electron.vite.config.ts @@ -35,16 +35,31 @@ function copyResourcesPlugin(): Plugin { return { name: "copy-resources", writeBundle() { - const srcDir = resolve(resources, "sounds"); - const destDir = resolve(devPath, "resources/sounds"); + // Copy sounds + const soundsSrc = resolve(resources, "sounds"); + const soundsDest = resolve(devPath, "resources/sounds"); - if (existsSync(srcDir)) { - // Clean destination to avoid stale files - if (existsSync(destDir)) { - rmSync(destDir, { recursive: true }); + if (existsSync(soundsSrc)) { + if (existsSync(soundsDest)) { + rmSync(soundsDest, { recursive: true }); } - mkdirSync(destDir, { recursive: true }); - cpSync(srcDir, destDir, { recursive: true }); + mkdirSync(soundsDest, { recursive: true }); + cpSync(soundsSrc, soundsDest, { recursive: true }); + } + + // Copy database migrations from local-db package + const migrationsSrc = resolve( + __dirname, + "../../packages/local-db/drizzle", + ); + const migrationsDest = resolve(devPath, "resources/migrations"); + + if (existsSync(migrationsSrc)) { + if (existsSync(migrationsDest)) { + rmSync(migrationsDest, { recursive: true }); + } + mkdirSync(migrationsDest, { recursive: true }); + cpSync(migrationsSrc, migrationsDest, { recursive: true }); } }, }; @@ -86,6 +101,7 @@ export default defineConfig({ // Only externalize native modules that can't be bundled external: [ "electron", + "better-sqlite3", // Native module - must stay external "node-pty", // Native module - must stay external ], }, diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 3f0f0934479..fda6db3ec74 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -2,7 +2,7 @@ "name": "@superset/desktop", "productName": "Superset", "description": "The last developer tool you'll ever need", - "version": "0.0.33", + "version": "0.0.34", "main": "./dist/main/index.js", "resources": "src/resources", "repository": { @@ -37,6 +37,7 @@ "@monaco-editor/react": "^4.7.0", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", + "@superset/local-db": "workspace:*", "@superset/shared": "workspace:*", "@superset/trpc": "workspace:*", "@superset/ui": "workspace:*", @@ -57,12 +58,14 @@ "@xterm/addon-web-links": "^0.11.0", "@xterm/addon-webgl": "^0.18.0", "@xterm/xterm": "^5.5.0", + "better-sqlite3": "12.5.0", "clsx": "^2.1.1", "culori": "^4.0.2", "date-fns": "^4.1.0", "default-shell": "^2.2.0", "dnd-core": "^16.0.1", "dotenv": "^17.2.3", + "drizzle-orm": "0.45.1", "electron-router-dom": "^2.1.0", "electron-updater": "6", "execa": "^9.6.0", @@ -106,6 +109,7 @@ "@biomejs/biome": "^2.3.8", "@superset/typescript": "workspace:*", "@tailwindcss/vite": "^4.0.9", + "@types/better-sqlite3": "^7.6.13", "@types/culori": "^4.0.1", "@types/http-proxy": "^1.17.17", "@types/lodash": "^4.17.20", diff --git a/apps/desktop/scripts/copy-native-modules.ts b/apps/desktop/scripts/copy-native-modules.ts index 531e6fc3d7b..2ea972edae9 100644 --- a/apps/desktop/scripts/copy-native-modules.ts +++ b/apps/desktop/scripts/copy-native-modules.ts @@ -16,42 +16,67 @@ import { cpSync, existsSync, lstatSync, realpathSync, rmSync } from "node:fs"; import { dirname, join } from "node:path"; -const NATIVE_MODULES = ["node-pty"] as const; +// Native modules that must exist for the app to work +const NATIVE_MODULES = ["better-sqlite3", "node-pty"] as const; -function prepareNativeModules() { - console.log("Preparing native modules for electron-builder..."); +// Dependencies of native modules that need to be copied (may be hoisted or symlinked) +const NATIVE_MODULE_DEPS = ["bindings", "file-uri-to-path"] as const; - const nodeModulesDir = join(dirname(import.meta.dirname), "node_modules"); - - for (const moduleName of NATIVE_MODULES) { - const modulePath = join(nodeModulesDir, moduleName); +function copyModuleIfSymlink( + nodeModulesDir: string, + moduleName: string, + required: boolean, +): boolean { + const modulePath = join(nodeModulesDir, moduleName); - if (!existsSync(modulePath)) { + if (!existsSync(modulePath)) { + if (required) { console.error(` [ERROR] ${moduleName} not found at ${modulePath}`); process.exit(1); } + console.log(` ${moduleName}: not found (skipping)`); + return false; + } - const stats = lstatSync(modulePath); + const stats = lstatSync(modulePath); - if (stats.isSymbolicLink()) { - // Resolve symlink to get real path - const realPath = realpathSync(modulePath); - console.log(` ${moduleName}: symlink -> replacing with real files`); - console.log(` Real path: ${realPath}`); + if (stats.isSymbolicLink()) { + // Resolve symlink to get real path + const realPath = realpathSync(modulePath); + console.log(` ${moduleName}: symlink -> replacing with real files`); + console.log(` Real path: ${realPath}`); - // Remove the symlink - rmSync(modulePath); + // Remove the symlink + rmSync(modulePath); - // Copy the actual files - cpSync(realPath, modulePath, { recursive: true }); + // Copy the actual files + cpSync(realPath, modulePath, { recursive: true }); - console.log(` Copied to: ${modulePath}`); - } else { - console.log(` ${moduleName}: already real directory (not a symlink)`); - } + console.log(` Copied to: ${modulePath}`); + } else { + console.log(` ${moduleName}: already real directory (not a symlink)`); + } + + return true; +} + +function prepareNativeModules() { + console.log("Preparing native modules for electron-builder..."); + + const nodeModulesDir = join(dirname(import.meta.dirname), "node_modules"); + + // Copy required native modules + for (const moduleName of NATIVE_MODULES) { + copyModuleIfSymlink(nodeModulesDir, moduleName, true); + } + + // Copy native module dependencies (not required but needed if present) + console.log("\nPreparing native module dependencies..."); + for (const moduleName of NATIVE_MODULE_DEPS) { + copyModuleIfSymlink(nodeModulesDir, moduleName, false); } - console.log("Done!"); + console.log("\nDone!"); } prepareNativeModules(); diff --git a/apps/desktop/src/lib/trpc/routers/changes/branches.ts b/apps/desktop/src/lib/trpc/routers/changes/branches.ts index bdfed4d97ba..bc63ab91aa3 100644 --- a/apps/desktop/src/lib/trpc/routers/changes/branches.ts +++ b/apps/desktop/src/lib/trpc/routers/changes/branches.ts @@ -1,4 +1,6 @@ -import { db } from "main/lib/db"; +import { worktrees } from "@superset/local-db"; +import { eq } from "drizzle-orm"; +import { localDb } from "main/lib/local-db"; import simpleGit from "simple-git"; import { z } from "zod"; import { publicProcedure, router } from "../.."; @@ -59,24 +61,30 @@ export const createBranchesRouter = () => { .mutation(async ({ input }): Promise<{ success: boolean }> => { const git = simpleGit(input.worktreePath); - const worktree = db.data.worktrees.find( - (wt) => wt.path === input.worktreePath, - ); + const worktree = localDb + .select() + .from(worktrees) + .where(eq(worktrees.path, input.worktreePath)) + .get(); if (!worktree) { throw new Error(`No worktree found at path "${input.worktreePath}"`); } await git.checkout(input.branch); - await db.update((data) => { - const wt = data.worktrees.find((w) => w.path === input.worktreePath); - if (wt) { - wt.branch = input.branch; - if (wt.gitStatus) { - wt.gitStatus.branch = input.branch; - } - } - }); + // Update the branch in the worktree record + const gitStatus = worktree.gitStatus + ? { ...worktree.gitStatus, branch: input.branch } + : null; + + localDb + .update(worktrees) + .set({ + branch: input.branch, + gitStatus, + }) + .where(eq(worktrees.path, input.worktreePath)) + .run(); return { success: true }; }), diff --git a/apps/desktop/src/lib/trpc/routers/config/config.ts b/apps/desktop/src/lib/trpc/routers/config/config.ts index 8a925af2252..628782f14c4 100644 --- a/apps/desktop/src/lib/trpc/routers/config/config.ts +++ b/apps/desktop/src/lib/trpc/routers/config/config.ts @@ -1,6 +1,8 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { db } from "main/lib/db"; +import { projects } from "@superset/local-db"; +import { eq } from "drizzle-orm"; +import { localDb } from "main/lib/local-db"; import { z } from "zod"; import { publicProcedure, router } from "../.."; @@ -41,7 +43,11 @@ export const createConfigRouter = () => { shouldShowConfigToast: publicProcedure .input(z.object({ projectId: z.string() })) .query(({ input }) => { - const project = db.data.projects.find((p) => p.id === input.projectId); + const project = localDb + .select() + .from(projects) + .where(eq(projects.id, input.projectId)) + .get(); if (!project) { return false; } @@ -57,13 +63,12 @@ export const createConfigRouter = () => { // Mark the config toast as dismissed for a project dismissConfigToast: publicProcedure .input(z.object({ projectId: z.string() })) - .mutation(async ({ input }) => { - await db.update((data) => { - const project = data.projects.find((p) => p.id === input.projectId); - if (project) { - project.configToastDismissed = true; - } - }); + .mutation(({ input }) => { + localDb + .update(projects) + .set({ configToastDismissed: true }) + .where(eq(projects.id, input.projectId)) + .run(); return { success: true }; }), @@ -71,7 +76,11 @@ export const createConfigRouter = () => { getConfigFilePath: publicProcedure .input(z.object({ projectId: z.string() })) .query(({ input }) => { - const project = db.data.projects.find((p) => p.id === input.projectId); + const project = localDb + .select() + .from(projects) + .where(eq(projects.id, input.projectId)) + .get(); if (!project) { return null; } @@ -82,7 +91,11 @@ export const createConfigRouter = () => { getConfigContent: publicProcedure .input(z.object({ projectId: z.string() })) .query(({ input }) => { - const project = db.data.projects.find((p) => p.id === input.projectId); + const project = localDb + .select() + .from(projects) + .where(eq(projects.id, input.projectId)) + .get(); if (!project) { return { content: null, exists: false }; } diff --git a/apps/desktop/src/lib/trpc/routers/external/helpers.ts b/apps/desktop/src/lib/trpc/routers/external/helpers.ts index 9be94a7ee2d..db981d0a185 100644 --- a/apps/desktop/src/lib/trpc/routers/external/helpers.ts +++ b/apps/desktop/src/lib/trpc/routers/external/helpers.ts @@ -1,6 +1,6 @@ import { spawn } from "node:child_process"; import nodePath from "node:path"; -import { EXTERNAL_APPS, type ExternalApp } from "main/lib/db/schemas"; +import { EXTERNAL_APPS, type ExternalApp } from "@superset/local-db"; /** Map of app IDs to their macOS application names */ const APP_NAMES: Record = { diff --git a/apps/desktop/src/lib/trpc/routers/external/index.ts b/apps/desktop/src/lib/trpc/routers/external/index.ts index 0551c57d7df..a0e2f9c659c 100644 --- a/apps/desktop/src/lib/trpc/routers/external/index.ts +++ b/apps/desktop/src/lib/trpc/routers/external/index.ts @@ -1,5 +1,6 @@ +import { settings } from "@superset/local-db"; import { clipboard, shell } from "electron"; -import { db } from "main/lib/db"; +import { localDb } from "main/lib/local-db"; import { z } from "zod"; import { publicProcedure, router } from "../.."; import { @@ -54,9 +55,14 @@ export const createExternalRouter = () => { }), ) .mutation(async ({ input }) => { - await db.update((data) => { - data.settings.lastUsedApp = input.app; - }); + localDb + .insert(settings) + .values({ id: 1, lastUsedApp: input.app }) + .onConflictDoUpdate({ + target: settings.id, + set: { lastUsedApp: input.app }, + }) + .run(); await openPathInApp(input.path, input.app); }), @@ -75,7 +81,8 @@ export const createExternalRouter = () => { ) .mutation(async ({ input }) => { const filePath = resolvePath(input.path, input.cwd); - const app = db.data.settings.lastUsedApp ?? "cursor"; + const settingsRow = localDb.select().from(settings).get(); + const app = settingsRow?.lastUsedApp ?? "cursor"; await openPathInApp(filePath, app); }), }); diff --git a/apps/desktop/src/lib/trpc/routers/projects/projects.ts b/apps/desktop/src/lib/trpc/routers/projects/projects.ts index 15a02853d58..c3ea40d1df9 100644 --- a/apps/desktop/src/lib/trpc/routers/projects/projects.ts +++ b/apps/desktop/src/lib/trpc/routers/projects/projects.ts @@ -1,13 +1,18 @@ import { existsSync } from "node:fs"; import { access } from "node:fs/promises"; import { basename, join } from "node:path"; +import { + projects, + type SelectProject, + settings, + workspaces, +} from "@superset/local-db"; +import { desc, eq, inArray } from "drizzle-orm"; import type { BrowserWindow } from "electron"; import { dialog } from "electron"; import { track } from "main/lib/analytics"; -import { db } from "main/lib/db"; -import type { Project } from "main/lib/db/schemas"; +import { localDb } from "main/lib/local-db"; import { terminalManager } from "main/lib/terminal"; -import { nanoid } from "nanoid"; import { PROJECT_COLOR_VALUES } from "shared/constants/project-colors"; import simpleGit from "simple-git"; import { z } from "zod"; @@ -15,6 +20,8 @@ import { publicProcedure, router } from "../.."; import { getDefaultBranch, getGitRoot } from "../workspaces/utils/git"; import { assignRandomColor } from "./utils/colors"; +type Project = SelectProject; + // Return types for openNew procedure type OpenNewCanceled = { canceled: true }; type OpenNewSuccess = { canceled: false; project: Project }; @@ -35,39 +42,34 @@ export type OpenNewResult = * If a project with the same mainRepoPath exists, updates lastOpenedAt. * Otherwise, creates a new project. */ -async function upsertProject( - mainRepoPath: string, - defaultBranch: string, -): Promise { +function upsertProject(mainRepoPath: string, defaultBranch: string): Project { const name = basename(mainRepoPath); - let project = db.data.projects.find((p) => p.mainRepoPath === mainRepoPath); + const existing = localDb + .select() + .from(projects) + .where(eq(projects.mainRepoPath, mainRepoPath)) + .get(); + + if (existing) { + localDb + .update(projects) + .set({ lastOpenedAt: Date.now(), defaultBranch }) + .where(eq(projects.id, existing.id)) + .run(); + return { ...existing, lastOpenedAt: Date.now(), defaultBranch }; + } - if (project) { - await db.update((data) => { - const p = data.projects.find((p) => p.id === project?.id); - if (p) { - p.lastOpenedAt = Date.now(); - p.defaultBranch = defaultBranch; - } - }); - } else { - project = { - id: nanoid(), + const project = localDb + .insert(projects) + .values({ mainRepoPath, name, color: assignRandomColor(), - tabOrder: null, - lastOpenedAt: Date.now(), - createdAt: Date.now(), defaultBranch, - }; - - await db.update((data) => { - // biome-ignore lint/style/noNonNullAssertion: project is assigned above, TypeScript can't see it inside callback - data.projects.push(project!); - }); - } + }) + .returning() + .get(); return project; } @@ -144,13 +146,21 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { get: publicProcedure .input(z.object({ id: z.string() })) .query(({ input }): Project | null => { - return db.data.projects.find((p) => p.id === input.id) ?? null; + return ( + localDb + .select() + .from(projects) + .where(eq(projects.id, input.id)) + .get() ?? null + ); }), getRecents: publicProcedure.query((): Project[] => { - return db.data.projects - .slice() - .sort((a, b) => b.lastOpenedAt - a.lastOpenedAt); + return localDb + .select() + .from(projects) + .orderBy(desc(projects.lastOpenedAt)) + .all(); }), getBranches: publicProcedure @@ -162,9 +172,11 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { branches: Array<{ name: string; lastCommitDate: number }>; defaultBranch: string; }> => { - const project = db.data.projects.find( - (p) => p.id === input.projectId, - ); + const project = localDb + .select() + .from(projects) + .where(eq(projects.id, input.projectId)) + .get(); if (!project) { throw new Error(`Project ${input.projectId} not found`); } @@ -287,7 +299,7 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { } const defaultBranch = await getDefaultBranch(mainRepoPath); - const project = await upsertProject(mainRepoPath, defaultBranch); + const project = upsertProject(mainRepoPath, defaultBranch); return { canceled: false, @@ -337,7 +349,7 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { const branchSummary = await git.branch(); const defaultBranch = branchSummary.current || "main"; - const project = await upsertProject(input.path, defaultBranch); + const project = upsertProject(input.path, defaultBranch); return { project }; }), @@ -392,38 +404,33 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { const clonePath = join(targetDir, repoName); // Check if we already have a project for this path - const existingProject = db.data.projects.find( - (p) => p.mainRepoPath === clonePath, - ); + const existingProject = localDb + .select() + .from(projects) + .where(eq(projects.mainRepoPath, clonePath)) + .get(); if (existingProject) { // Verify the filesystem path still exists try { await access(clonePath); // Directory exists - update lastOpenedAt and return existing project - await db.update((data) => { - const p = data.projects.find( - (p) => p.id === existingProject.id, - ); - if (p) { - p.lastOpenedAt = Date.now(); - } - }); + localDb + .update(projects) + .set({ lastOpenedAt: Date.now() }) + .where(eq(projects.id, existingProject.id)) + .run(); return { canceled: false as const, success: true as const, - project: existingProject, + project: { ...existingProject, lastOpenedAt: Date.now() }, }; } catch { // Directory is missing - remove the stale project record and continue with clone - await db.update((data) => { - const index = data.projects.findIndex( - (p) => p.id === existingProject.id, - ); - if (index !== -1) { - data.projects.splice(index, 1); - } - }); + localDb + .delete(projects) + .where(eq(projects.id, existingProject.id)) + .run(); // Continue to normal creation flow below } } @@ -444,20 +451,16 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { // Create new project const name = basename(clonePath); const defaultBranch = await getDefaultBranch(clonePath); - const project: Project = { - id: nanoid(), - mainRepoPath: clonePath, - name, - color: assignRandomColor(), - tabOrder: null, - lastOpenedAt: Date.now(), - createdAt: Date.now(), - defaultBranch, - }; - - await db.update((data) => { - data.projects.push(project); - }); + const project = localDb + .insert(projects) + .values({ + mainRepoPath: clonePath, + name, + color: assignRandomColor(), + defaultBranch, + }) + .returning() + .get(); return { canceled: false as const, @@ -491,23 +494,27 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { }), }), ) - .mutation(async ({ input }) => { - await db.update((data) => { - const project = data.projects.find((p) => p.id === input.id); - if (!project) { - throw new Error(`Project ${input.id} not found`); - } - - if (input.patch.name !== undefined) { - project.name = input.patch.name; - } - - if (input.patch.color !== undefined) { - project.color = input.patch.color; - } + .mutation(({ input }) => { + const project = localDb + .select() + .from(projects) + .where(eq(projects.id, input.id)) + .get(); + if (!project) { + throw new Error(`Project ${input.id} not found`); + } - project.lastOpenedAt = Date.now(); - }); + localDb + .update(projects) + .set({ + ...(input.patch.name !== undefined && { name: input.patch.name }), + ...(input.patch.color !== undefined && { + color: input.patch.color, + }), + lastOpenedAt: Date.now(), + }) + .where(eq(projects.id, input.id)) + .run(); return { success: true }; }), @@ -519,34 +526,36 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { toIndex: z.number(), }), ) - .mutation(async ({ input }) => { - await db.update((data) => { - const { fromIndex, toIndex } = input; - - const activeProjects = data.projects - .filter((p) => p.tabOrder !== null) - // biome-ignore lint/style/noNonNullAssertion: filter guarantees tabOrder is not null - .sort((a, b) => a.tabOrder! - b.tabOrder!); - - if ( - fromIndex < 0 || - fromIndex >= activeProjects.length || - toIndex < 0 || - toIndex >= activeProjects.length - ) { - throw new Error("Invalid fromIndex or toIndex"); - } + .mutation(({ input }) => { + const { fromIndex, toIndex } = input; + + const activeProjects = localDb + .select() + .from(projects) + .where(eq(projects.tabOrder, projects.tabOrder)) // Just get all with non-null tabOrder + .all() + .filter((p) => p.tabOrder !== null) + .sort((a, b) => (a.tabOrder ?? 0) - (b.tabOrder ?? 0)); + + if ( + fromIndex < 0 || + fromIndex >= activeProjects.length || + toIndex < 0 || + toIndex >= activeProjects.length + ) { + throw new Error("Invalid fromIndex or toIndex"); + } - const [removed] = activeProjects.splice(fromIndex, 1); - activeProjects.splice(toIndex, 0, removed); + const [removed] = activeProjects.splice(fromIndex, 1); + activeProjects.splice(toIndex, 0, removed); - activeProjects.forEach((project, index) => { - const p = data.projects.find((p) => p.id === project.id); - if (p) { - p.tabOrder = index; - } - }); - }); + for (let i = 0; i < activeProjects.length; i++) { + localDb + .update(projects) + .set({ tabOrder: i }) + .where(eq(projects.id, activeProjects[i].id)) + .run(); + } return { success: true }; }), @@ -554,16 +563,22 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { close: publicProcedure .input(z.object({ id: z.string() })) .mutation(async ({ input }) => { - const project = db.data.projects.find((p) => p.id === input.id); + const project = localDb + .select() + .from(projects) + .where(eq(projects.id, input.id)) + .get(); if (!project) { throw new Error("Project not found"); } // Find all workspaces for this project - const projectWorkspaces = db.data.workspaces.filter( - (w) => w.projectId === input.id, - ); + const projectWorkspaces = localDb + .select() + .from(workspaces) + .where(eq(workspaces.projectId, input.id)) + .all(); // Kill all terminal processes in all workspaces of this project let totalFailed = 0; @@ -574,33 +589,44 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => { totalFailed += terminalResult.failed; } - // Remove all workspace records and hide the project - await db.update((data) => { - // Remove all workspaces for this project - data.workspaces = data.workspaces.filter( - (w) => w.projectId !== input.id, - ); + // Get workspace IDs for cleanup + const closedWorkspaceIds = projectWorkspaces.map((w) => w.id); - // Hide the project by setting tabOrder to null - const p = data.projects.find((p) => p.id === input.id); - if (p) { - p.tabOrder = null; - } + // Remove all workspaces for this project + if (closedWorkspaceIds.length > 0) { + localDb + .delete(workspaces) + .where(inArray(workspaces.id, closedWorkspaceIds)) + .run(); + } - // Update active workspace if it was in this project - const closedWorkspaceIds = new Set( - projectWorkspaces.map((w) => w.id), - ); - if ( - data.settings.lastActiveWorkspaceId && - closedWorkspaceIds.has(data.settings.lastActiveWorkspaceId) - ) { - const sorted = data.workspaces - .slice() - .sort((a, b) => b.lastOpenedAt - a.lastOpenedAt); - data.settings.lastActiveWorkspaceId = sorted[0]?.id || undefined; - } - }); + // Hide the project by setting tabOrder to null + localDb + .update(projects) + .set({ tabOrder: null }) + .where(eq(projects.id, input.id)) + .run(); + + // Update active workspace if it was in this project + const currentSettings = localDb.select().from(settings).get(); + if ( + currentSettings?.lastActiveWorkspaceId && + closedWorkspaceIds.includes(currentSettings.lastActiveWorkspaceId) + ) { + const remainingWorkspaces = localDb + .select() + .from(workspaces) + .orderBy(desc(workspaces.lastOpenedAt)) + .all(); + + localDb + .update(settings) + .set({ + lastActiveWorkspaceId: remainingWorkspaces[0]?.id ?? null, + }) + .where(eq(settings.id, 1)) + .run(); + } const terminalWarning = totalFailed > 0 diff --git a/apps/desktop/src/lib/trpc/routers/settings/index.ts b/apps/desktop/src/lib/trpc/routers/settings/index.ts index 7a2e01202b5..aead6a4b1fc 100644 --- a/apps/desktop/src/lib/trpc/routers/settings/index.ts +++ b/apps/desktop/src/lib/trpc/routers/settings/index.ts @@ -1,18 +1,31 @@ -import { db } from "main/lib/db"; -import { nanoid } from "nanoid"; +import { settings, type TerminalPreset } from "@superset/local-db"; +import { localDb } from "main/lib/local-db"; import { DEFAULT_RINGTONE_ID, RINGTONES } from "shared/ringtones"; import { z } from "zod"; import { publicProcedure, router } from "../.."; const VALID_RINGTONE_IDS = RINGTONES.map((r) => r.id); +/** + * Gets the settings row, creating one if it doesn't exist + */ +function getSettings() { + let row = localDb.select().from(settings).get(); + if (!row) { + row = localDb.insert(settings).values({ id: 1 }).returning().get(); + } + return row; +} + export const createSettingsRouter = () => { return router({ getLastUsedApp: publicProcedure.query(() => { - return db.data.settings.lastUsedApp ?? "cursor"; + const row = getSettings(); + return row.lastUsedApp ?? "cursor"; }), getTerminalPresets: publicProcedure.query(() => { - return db.data.settings.terminalPresets ?? []; + const row = getSettings(); + return row.terminalPresets ?? []; }), createTerminalPreset: publicProcedure .input( @@ -23,18 +36,24 @@ export const createSettingsRouter = () => { commands: z.array(z.string()), }), ) - .mutation(async ({ input }) => { - const preset = { - id: nanoid(), + .mutation(({ input }) => { + const preset: TerminalPreset = { + id: crypto.randomUUID(), ...input, }; - await db.update((data) => { - if (!data.settings.terminalPresets) { - data.settings.terminalPresets = []; - } - data.settings.terminalPresets.push(preset); - }); + const row = getSettings(); + const presets = row.terminalPresets ?? []; + presets.push(preset); + + localDb + .insert(settings) + .values({ id: 1, terminalPresets: presets }) + .onConflictDoUpdate({ + target: settings.id, + set: { terminalPresets: presets }, + }) + .run(); return preset; }), @@ -51,41 +70,56 @@ export const createSettingsRouter = () => { }), }), ) - .mutation(async ({ input }) => { - await db.update((data) => { - const presets = data.settings.terminalPresets ?? []; - const preset = presets.find((p) => p.id === input.id); - - if (!preset) { - throw new Error(`Preset ${input.id} not found`); - } - - if (input.patch.name !== undefined) preset.name = input.patch.name; - if (input.patch.description !== undefined) - preset.description = input.patch.description; - if (input.patch.cwd !== undefined) preset.cwd = input.patch.cwd; - if (input.patch.commands !== undefined) - preset.commands = input.patch.commands; - }); + .mutation(({ input }) => { + const row = getSettings(); + const presets = row.terminalPresets ?? []; + const preset = presets.find((p) => p.id === input.id); + + if (!preset) { + throw new Error(`Preset ${input.id} not found`); + } + + if (input.patch.name !== undefined) preset.name = input.patch.name; + if (input.patch.description !== undefined) + preset.description = input.patch.description; + if (input.patch.cwd !== undefined) preset.cwd = input.patch.cwd; + if (input.patch.commands !== undefined) + preset.commands = input.patch.commands; + + localDb + .insert(settings) + .values({ id: 1, terminalPresets: presets }) + .onConflictDoUpdate({ + target: settings.id, + set: { terminalPresets: presets }, + }) + .run(); return { success: true }; }), deleteTerminalPreset: publicProcedure .input(z.object({ id: z.string() })) - .mutation(async ({ input }) => { - await db.update((data) => { - const presets = data.settings.terminalPresets ?? []; - data.settings.terminalPresets = presets.filter( - (p) => p.id !== input.id, - ); - }); + .mutation(({ input }) => { + const row = getSettings(); + const presets = row.terminalPresets ?? []; + const filteredPresets = presets.filter((p) => p.id !== input.id); + + localDb + .insert(settings) + .values({ id: 1, terminalPresets: filteredPresets }) + .onConflictDoUpdate({ + target: settings.id, + set: { terminalPresets: filteredPresets }, + }) + .run(); return { success: true }; }), - getSelectedRingtoneId: publicProcedure.query(async () => { - const storedId = db.data.settings.selectedRingtoneId; + getSelectedRingtoneId: publicProcedure.query(() => { + const row = getSettings(); + const storedId = row.selectedRingtoneId; // If no stored ID, return default if (!storedId) { @@ -101,23 +135,33 @@ export const createSettingsRouter = () => { console.warn( `[settings] Invalid ringtone ID "${storedId}" found, resetting to default`, ); - await db.update((data) => { - data.settings.selectedRingtoneId = DEFAULT_RINGTONE_ID; - }); + localDb + .insert(settings) + .values({ id: 1, selectedRingtoneId: DEFAULT_RINGTONE_ID }) + .onConflictDoUpdate({ + target: settings.id, + set: { selectedRingtoneId: DEFAULT_RINGTONE_ID }, + }) + .run(); return DEFAULT_RINGTONE_ID; }), setSelectedRingtoneId: publicProcedure .input(z.object({ ringtoneId: z.string() })) - .mutation(async ({ input }) => { + .mutation(({ input }) => { // Validate ringtone ID exists if (!VALID_RINGTONE_IDS.includes(input.ringtoneId)) { throw new Error(`Invalid ringtone ID: ${input.ringtoneId}`); } - await db.update((data) => { - data.settings.selectedRingtoneId = input.ringtoneId; - }); + localDb + .insert(settings) + .values({ id: 1, selectedRingtoneId: input.ringtoneId }) + .onConflictDoUpdate({ + target: settings.id, + set: { selectedRingtoneId: input.ringtoneId }, + }) + .run(); return { success: true }; }), diff --git a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts index 190bc9abae6..725c4592d80 100644 --- a/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts +++ b/apps/desktop/src/lib/trpc/routers/terminal/terminal.ts @@ -1,7 +1,9 @@ import fs from "node:fs/promises"; import path from "node:path"; +import { projects, workspaces, worktrees } from "@superset/local-db"; import { observable } from "@trpc/server/observable"; -import { db } from "main/lib/db"; +import { eq } from "drizzle-orm"; +import { localDb } from "main/lib/local-db"; import { terminalManager } from "main/lib/terminal"; import { z } from "zod"; import { publicProcedure, router } from "../.."; @@ -48,7 +50,11 @@ export const createTerminalRouter = () => { } = input; // Resolve cwd: absolute paths stay as-is, relative paths resolve against workspace path - const workspace = db.data.workspaces.find((w) => w.id === workspaceId); + const workspace = localDb + .select() + .from(workspaces) + .where(eq(workspaces.id, workspaceId)) + .get(); const workspacePath = workspace ? (getWorkspacePath(workspace) ?? undefined) : undefined; @@ -56,7 +62,11 @@ export const createTerminalRouter = () => { // Get project info for environment variables const project = workspace - ? db.data.projects.find((p) => p.id === workspace.projectId) + ? localDb + .select() + .from(projects) + .where(eq(projects.id, workspace.projectId)) + .get() : undefined; const result = await terminalManager.createOrAttach({ @@ -165,15 +175,25 @@ export const createTerminalRouter = () => { */ getWorkspaceCwd: publicProcedure .input(z.string()) - .query(async ({ input: workspaceId }) => { - const workspace = db.data.workspaces.find((w) => w.id === workspaceId); + .query(({ input: workspaceId }) => { + const workspace = localDb + .select() + .from(workspaces) + .where(eq(workspaces.id, workspaceId)) + .get(); if (!workspace) { return undefined; } - const worktree = db.data.worktrees.find( - (wt) => wt.id === workspace.worktreeId, - ); + if (!workspace.worktreeId) { + return undefined; + } + + const worktree = localDb + .select() + .from(worktrees) + .where(eq(worktrees.id, workspace.worktreeId)) + .get(); return worktree?.path; }), diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts index 5266f1e42c6..556c89d6d8a 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/github/github.ts @@ -1,6 +1,6 @@ import { execFile } from "node:child_process"; import { promisify } from "node:util"; -import type { CheckItem, GitHubStatus } from "main/lib/db/schemas"; +import type { CheckItem, GitHubStatus } from "@superset/local-db"; import { branchExistsOnRemote } from "../git"; import { type GHPRResponse, diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/utils/worktree.ts b/apps/desktop/src/lib/trpc/routers/workspaces/utils/worktree.ts index 1a83fdeae20..0ecccdc326b 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/utils/worktree.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/utils/worktree.ts @@ -1,11 +1,16 @@ -import { db } from "main/lib/db"; -import type { Workspace } from "main/lib/db/schemas"; +import { projects, type SelectWorkspace, worktrees } from "@superset/local-db"; +import { eq } from "drizzle-orm"; +import { localDb } from "main/lib/local-db"; /** * Gets the worktree path for a workspace by worktreeId */ export function getWorktreePath(worktreeId: string): string | undefined { - const worktree = db.data.worktrees.find((w) => w.id === worktreeId); + const worktree = localDb + .select() + .from(worktrees) + .where(eq(worktrees.id, worktreeId)) + .get(); return worktree?.path; } @@ -14,17 +19,23 @@ export function getWorktreePath(worktreeId: string): string | undefined { * For worktree workspaces: returns the worktree path * For branch workspaces: returns the main repo path */ -export function getWorkspacePath(workspace: Workspace): string | null { +export function getWorkspacePath(workspace: SelectWorkspace): string | null { if (workspace.type === "branch") { - const project = db.data.projects.find((p) => p.id === workspace.projectId); + const project = localDb + .select() + .from(projects) + .where(eq(projects.id, workspace.projectId)) + .get(); return project?.mainRepoPath ?? null; } // For worktree type, use worktree path if (workspace.worktreeId) { - const worktree = db.data.worktrees.find( - (wt) => wt.id === workspace.worktreeId, - ); + const worktree = localDb + .select() + .from(worktrees) + .where(eq(worktrees.id, workspace.worktreeId)) + .get(); return worktree?.path ?? null; } diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.test.ts b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.test.ts deleted file mode 100644 index d9f640d6811..00000000000 --- a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.test.ts +++ /dev/null @@ -1,336 +0,0 @@ -import { beforeEach, describe, expect, it, mock } from "bun:test"; - -/** - * These tests focus on the canDelete endpoint which parses git worktree output. - * This is valuable to test because: - * 1. Git output format is external/could change - * 2. Path matching edge cases are tricky (prefix matching, whitespace) - * - * Note: create/delete tests were removed because they were mostly testing - * mock behavior rather than real behavior. Those paths are better tested - * via integration tests or E2E tests. - */ - -// Mock the database with minimal data needed for canDelete tests -const mockDb = { - data: { - workspaces: [ - { - id: "workspace-1", - projectId: "project-1", - worktreeId: "worktree-1", - name: "Test Workspace", - tabOrder: 0, - createdAt: Date.now(), - updatedAt: Date.now(), - lastOpenedAt: Date.now(), - }, - ], - worktrees: [ - { - id: "worktree-1", - projectId: "project-1", - path: "/path/to/worktree", - branch: "test-branch", - createdAt: Date.now(), - }, - ], - projects: [ - { - id: "project-1", - name: "Test Project", - mainRepoPath: "/path/to/repo", - color: "#ff0000", - tabOrder: 0, - createdAt: Date.now(), - lastOpenedAt: Date.now(), - }, - ], - settings: { - lastActiveWorkspaceId: "workspace-1", - }, - }, - update: mock(async (fn: (data: typeof mockDb.data) => void) => { - fn(mockDb.data); - }), -}; - -mock.module("main/lib/db", () => ({ - db: mockDb, -})); - -// Mock git utilities - we don't test these here, just need them to not fail -mock.module("./utils/git", () => ({ - createWorktree: mock(() => Promise.resolve()), - removeWorktree: mock(() => Promise.resolve()), - generateBranchName: mock(() => "test-branch-123"), -})); - -import { createWorkspacesRouter } from "./workspaces"; - -// Helper to mock simple-git with specific worktree list output -function mockSimpleGitWithWorktreeList( - worktreeListOutput: string, - options?: { isClean?: boolean; unpushedCommitCount?: number }, -) { - const isClean = options?.isClean ?? true; - const unpushedCommitCount = options?.unpushedCommitCount ?? 0; - const mockGit = { - raw: mock((args: string[]) => { - // Handle worktree list - if (args[0] === "worktree" && args[1] === "list") { - return Promise.resolve(worktreeListOutput); - } - // Handle rev-list for unpushed commits check - if (args[0] === "rev-list" && args[1] === "--count") { - return Promise.resolve(String(unpushedCommitCount)); - } - return Promise.resolve(""); - }), - status: mock(() => Promise.resolve({ isClean: () => isClean })), - }; - mock.module("simple-git", () => ({ - default: mock(() => mockGit), - })); - return mockGit; -} - -function mockSimpleGitWithError(error: Error) { - const mockGit = { - raw: mock(() => Promise.reject(error)), - status: mock(() => Promise.resolve({ isClean: () => true })), - }; - mock.module("simple-git", () => ({ - default: mock(() => mockGit), - })); - return mockGit; -} - -// Reset mock data before each test -beforeEach(() => { - mockDb.data.worktrees = [ - { - id: "worktree-1", - projectId: "project-1", - path: "/path/to/worktree", - branch: "test-branch", - createdAt: Date.now(), - }, - ]; -}); - -describe("workspaces router - canDelete", () => { - it("returns true when worktree exists in git", async () => { - mockSimpleGitWithWorktreeList( - "worktree /path/to/worktree\nHEAD abc123\nbranch refs/heads/test-branch", - ); - - 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("returns warning when worktree not found in git", async () => { - mockSimpleGitWithWorktreeList( - "worktree /path/to/other-worktree\nHEAD def456\nbranch refs/heads/other-branch", - ); - - 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("returns false when git check fails", async () => { - mockSimpleGitWithError(new Error("Git error")); - - 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("uses exact path matching - does not match substrings", async () => { - // "/path/to/worktree-backup" should NOT match "/path/to/worktree" - mockSimpleGitWithWorktreeList( - "worktree /path/to/worktree-backup\nHEAD abc123\nbranch refs/heads/backup", - ); - - 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("handles trailing whitespace in git output", async () => { - mockSimpleGitWithWorktreeList( - "worktree /path/to/worktree \nHEAD abc123\nbranch refs/heads/test-branch", - ); - - const router = createWorkspacesRouter(); - const caller = router.createCaller({}); - const result = await caller.canDelete({ id: "workspace-1" }); - - expect(result.canDelete).toBe(true); - expect(result.warning).toBeNull(); - }); - - it("handles path that is prefix of another path", async () => { - mockDb.data.worktrees = [ - { - id: "worktree-1", - projectId: "project-1", - path: "/path/to/main", - branch: "test-branch", - createdAt: Date.now(), - }, - ]; - - // Git has "/path/to/main-backup" and "/path/to/main2" but NOT "/path/to/main" - mockSimpleGitWithWorktreeList( - "worktree /path/to/main-backup\nHEAD abc123\nbranch refs/heads/backup\n\nworktree /path/to/main2\nHEAD def456\nbranch refs/heads/other", - ); - - 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("passes --porcelain flag to git worktree list", async () => { - const mockGit = mockSimpleGitWithWorktreeList( - "worktree /path/to/worktree\nHEAD abc123\nbranch refs/heads/test-branch", - ); - - const router = createWorkspacesRouter(); - const caller = router.createCaller({}); - await caller.canDelete({ id: "workspace-1" }); - - expect(mockGit.raw).toHaveBeenCalledWith([ - "worktree", - "list", - "--porcelain", - ]); - }); - - it("returns hasChanges: false when worktree is clean", async () => { - mockSimpleGitWithWorktreeList( - "worktree /path/to/worktree\nHEAD abc123\nbranch refs/heads/test-branch", - { isClean: true }, - ); - - const router = createWorkspacesRouter(); - const caller = router.createCaller({}); - const result = await caller.canDelete({ id: "workspace-1" }); - - expect(result.canDelete).toBe(true); - expect(result.hasChanges).toBe(false); - }); - - it("returns hasChanges: true when worktree has uncommitted changes", async () => { - mockSimpleGitWithWorktreeList( - "worktree /path/to/worktree\nHEAD abc123\nbranch refs/heads/test-branch", - { isClean: false }, - ); - - const router = createWorkspacesRouter(); - const caller = router.createCaller({}); - const result = await caller.canDelete({ id: "workspace-1" }); - - expect(result.canDelete).toBe(true); - expect(result.hasChanges).toBe(true); - }); - - it("returns hasChanges: false when worktree not found in git", async () => { - mockSimpleGitWithWorktreeList( - "worktree /path/to/other-worktree\nHEAD def456\nbranch refs/heads/other-branch", - { isClean: false }, - ); - - 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"); - // hasChanges should be false when worktree doesn't exist - expect(result.hasChanges).toBe(false); - }); - - it("returns hasUnpushedCommits: false when all commits are pushed", async () => { - mockSimpleGitWithWorktreeList( - "worktree /path/to/worktree\nHEAD abc123\nbranch refs/heads/test-branch", - { isClean: true, unpushedCommitCount: 0 }, - ); - - const router = createWorkspacesRouter(); - const caller = router.createCaller({}); - const result = await caller.canDelete({ id: "workspace-1" }); - - expect(result.canDelete).toBe(true); - expect(result.hasUnpushedCommits).toBe(false); - }); - - it("returns hasUnpushedCommits: true when there are unpushed commits", async () => { - mockSimpleGitWithWorktreeList( - "worktree /path/to/worktree\nHEAD abc123\nbranch refs/heads/test-branch", - { isClean: true, unpushedCommitCount: 3 }, - ); - - const router = createWorkspacesRouter(); - const caller = router.createCaller({}); - const result = await caller.canDelete({ id: "workspace-1" }); - - expect(result.canDelete).toBe(true); - expect(result.hasUnpushedCommits).toBe(true); - }); - - it("returns hasUnpushedCommits: false when worktree not found in git", async () => { - mockSimpleGitWithWorktreeList( - "worktree /path/to/other-worktree\nHEAD def456\nbranch refs/heads/other-branch", - { isClean: true, unpushedCommitCount: 5 }, - ); - - 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"); - // hasUnpushedCommits should be false when worktree doesn't exist - expect(result.hasUnpushedCommits).toBe(false); - }); - - it("skips git checks when skipGitChecks is true", async () => { - const mockGit = mockSimpleGitWithWorktreeList( - "worktree /path/to/worktree\nHEAD abc123\nbranch refs/heads/test-branch", - { isClean: false, unpushedCommitCount: 5 }, - ); - - const router = createWorkspacesRouter(); - const caller = router.createCaller({}); - const result = await caller.canDelete({ - id: "workspace-1", - skipGitChecks: true, - }); - - expect(result.canDelete).toBe(true); - // When skipping git checks, these should be false (defaults) - expect(result.hasChanges).toBe(false); - expect(result.hasUnpushedCommits).toBe(false); - // git.status should not have been called - expect(mockGit.status).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts index 093eca9cc66..21705214431 100644 --- a/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts +++ b/apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts @@ -1,9 +1,16 @@ import { homedir } from "node:os"; import { join } from "node:path"; +import { + projects, + type SelectWorktree, + settings, + workspaces, + worktrees, +} from "@superset/local-db"; +import { and, desc, eq, isNotNull } from "drizzle-orm"; import { track } from "main/lib/analytics"; -import { db } from "main/lib/db"; +import { localDb } from "main/lib/local-db"; import { terminalManager } from "main/lib/terminal"; -import { nanoid } from "nanoid"; import { SUPERSET_DIR_NAME, WORKTREES_DIR_NAME } from "shared/constants"; import { z } from "zod"; import { publicProcedure, router } from "../.."; @@ -41,7 +48,11 @@ export const createWorkspacesRouter = () => { }), ) .mutation(async ({ input }) => { - const project = db.data.projects.find((p) => p.id === input.projectId); + const project = localDb + .select() + .from(projects) + .where(eq(projects.id, input.projectId)) + .get(); if (!project) { throw new Error(`Project ${input.projectId} not found`); } @@ -61,10 +72,11 @@ export const createWorkspacesRouter = () => { if (!defaultBranch) { defaultBranch = await getDefaultBranch(project.mainRepoPath); // Save it for future use - await db.update((data) => { - const p = data.projects.find((p) => p.id === project.id); - if (p) p.defaultBranch = defaultBranch; - }); + localDb + .update(projects) + .set({ defaultBranch }) + .where(eq(projects.id, project.id)) + .run(); } // Use provided baseBranch or fall back to default @@ -106,63 +118,80 @@ export const createWorkspacesRouter = () => { startPoint, ); - const worktree = { - id: nanoid(), - projectId: input.projectId, - path: worktreePath, - branch, - baseBranch: targetBranch, - createdAt: Date.now(), - gitStatus: { + // Insert worktree + const worktree = localDb + .insert(worktrees) + .values({ + projectId: input.projectId, + path: worktreePath, branch, - needsRebase: false, // Fresh off base branch, doesn't need rebase - lastRefreshed: Date.now(), - }, - }; - - const projectWorkspaces = db.data.workspaces.filter( - (w) => w.projectId === input.projectId, - ); + baseBranch: targetBranch, + gitStatus: { + branch, + needsRebase: false, + lastRefreshed: Date.now(), + }, + }) + .returning() + .get(); + + // Get max tab order for this project's workspaces + const projectWorkspaces = localDb + .select() + .from(workspaces) + .where(eq(workspaces.projectId, input.projectId)) + .all(); const maxTabOrder = projectWorkspaces.length > 0 ? Math.max(...projectWorkspaces.map((w) => w.tabOrder)) : -1; - const workspace = { - id: nanoid(), - projectId: input.projectId, - worktreeId: worktree.id, - type: "worktree" as const, - branch, - name: input.name ?? branch, - tabOrder: maxTabOrder + 1, - createdAt: Date.now(), - updatedAt: Date.now(), - lastOpenedAt: Date.now(), - }; - - await db.update((data) => { - 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) { - p.lastOpenedAt = Date.now(); + // Insert workspace + const workspace = localDb + .insert(workspaces) + .values({ + projectId: input.projectId, + worktreeId: worktree.id, + type: "worktree", + branch, + name: input.name ?? branch, + tabOrder: maxTabOrder + 1, + }) + .returning() + .get(); + + // Update settings + localDb + .insert(settings) + .values({ id: 1, lastActiveWorkspaceId: workspace.id }) + .onConflictDoUpdate({ + target: settings.id, + set: { lastActiveWorkspaceId: workspace.id }, + }) + .run(); + + // Update project + const activeProjects = localDb + .select() + .from(projects) + .where(isNotNull(projects.tabOrder)) + .all(); + const maxProjectTabOrder = + activeProjects.length > 0 + ? Math.max(...activeProjects.map((p) => p.tabOrder ?? 0)) + : -1; - if (p.tabOrder === null) { - const activeProjects = data.projects.filter( - (proj) => proj.tabOrder !== null, - ); - const maxProjectTabOrder = - activeProjects.length > 0 - ? // biome-ignore lint/style/noNonNullAssertion: filter guarantees tabOrder is not null - Math.max(...activeProjects.map((proj) => proj.tabOrder!)) - : -1; - p.tabOrder = maxProjectTabOrder + 1; - } - } - }); + localDb + .update(projects) + .set({ + lastOpenedAt: Date.now(), + tabOrder: + project.tabOrder === null + ? maxProjectTabOrder + 1 + : project.tabOrder, + }) + .where(eq(projects.id, input.projectId)) + .run(); // Load setup configuration from the main repo (where .superset/config.json lives) const setupConfig = loadSetupConfig(project.mainRepoPath); @@ -191,7 +220,11 @@ export const createWorkspacesRouter = () => { }), ) .mutation(async ({ input }) => { - const project = db.data.projects.find((p) => p.id === input.projectId); + const project = localDb + .select() + .from(projects) + .where(eq(projects.id, input.projectId)) + .get(); if (!project) { throw new Error(`Project ${input.projectId} not found`); } @@ -205,9 +238,16 @@ export const createWorkspacesRouter = () => { // If a specific branch was requested, check for conflict before checkout if (input.branch) { - const existingBranchWorkspace = db.data.workspaces.find( - (w) => w.projectId === input.projectId && w.type === "branch", - ); + const existingBranchWorkspace = localDb + .select() + .from(workspaces) + .where( + and( + eq(workspaces.projectId, input.projectId), + eq(workspaces.type, "branch"), + ), + ) + .get(); if ( existingBranchWorkspace && existingBranchWorkspace.branch !== branch @@ -220,77 +260,113 @@ export const createWorkspacesRouter = () => { await safeCheckoutBranch(project.mainRepoPath, input.branch); } - // Prepare new workspace (may not be used if existing found) - const workspace = { - id: nanoid(), - projectId: input.projectId, - worktreeId: undefined, - type: "branch" as const, - branch, - name: branch, // Name is always the branch for branch workspaces - tabOrder: 0, // Main workspace is always first - createdAt: Date.now(), - updatedAt: Date.now(), - lastOpenedAt: Date.now(), - }; - - // Track which workspace "wins" - makes concurrent calls idempotent - let returnedWorkspace: typeof workspace = workspace; - let wasExisting = false; - - await db.update((data) => { - // Atomic check: if branch workspace already exists, activate it - const existing = data.workspaces.find( - (w) => w.projectId === input.projectId && w.type === "branch", - ); - - if (existing) { - wasExisting = true; - returnedWorkspace = existing as typeof workspace; - data.settings.lastActiveWorkspaceId = existing.id; - existing.lastOpenedAt = Date.now(); - return; - } + // Check if branch workspace already exists + const existing = localDb + .select() + .from(workspaces) + .where( + and( + eq(workspaces.projectId, input.projectId), + eq(workspaces.type, "branch"), + ), + ) + .get(); + + if (existing) { + // Activate existing + localDb + .update(workspaces) + .set({ lastOpenedAt: Date.now() }) + .where(eq(workspaces.id, existing.id)) + .run(); + localDb + .insert(settings) + .values({ id: 1, lastActiveWorkspaceId: existing.id }) + .onConflictDoUpdate({ + target: settings.id, + set: { lastActiveWorkspaceId: existing.id }, + }) + .run(); + return { + workspace: { ...existing, lastOpenedAt: Date.now() }, + worktreePath: project.mainRepoPath, + projectId: project.id, + wasExisting: true, + }; + } - // Create new workspace - shift existing ones to make room at front - for (const ws of data.workspaces) { - if (ws.projectId === input.projectId) { - ws.tabOrder += 1; - } - } - data.workspaces.push(workspace); - data.settings.lastActiveWorkspaceId = workspace.id; + // Shift existing workspaces to make room at front + const projectWorkspaces = localDb + .select() + .from(workspaces) + .where(eq(workspaces.projectId, input.projectId)) + .all(); + for (const ws of projectWorkspaces) { + localDb + .update(workspaces) + .set({ tabOrder: ws.tabOrder + 1 }) + .where(eq(workspaces.id, ws.id)) + .run(); + } - const p = data.projects.find((p) => p.id === input.projectId); - if (p) { - p.lastOpenedAt = Date.now(); + // Insert new workspace + const workspace = localDb + .insert(workspaces) + .values({ + projectId: input.projectId, + type: "branch", + branch, + name: branch, + tabOrder: 0, + }) + .returning() + .get(); + + // Update settings + localDb + .insert(settings) + .values({ id: 1, lastActiveWorkspaceId: workspace.id }) + .onConflictDoUpdate({ + target: settings.id, + set: { lastActiveWorkspaceId: workspace.id }, + }) + .run(); + + // Update project + const activeProjects = localDb + .select() + .from(projects) + .where(isNotNull(projects.tabOrder)) + .all(); + const maxProjectTabOrder = + activeProjects.length > 0 + ? Math.max(...activeProjects.map((p) => p.tabOrder ?? 0)) + : -1; - if (p.tabOrder === null) { - const activeProjects = data.projects.filter( - (proj) => proj.tabOrder !== null, - ); - const maxProjectTabOrder = - activeProjects.length > 0 - ? // biome-ignore lint/style/noNonNullAssertion: filter guarantees tabOrder is not null - Math.max(...activeProjects.map((proj) => proj.tabOrder!)) - : -1; - p.tabOrder = maxProjectTabOrder + 1; - } - } - }); + localDb + .update(projects) + .set({ + lastOpenedAt: Date.now(), + tabOrder: + project.tabOrder === null + ? maxProjectTabOrder + 1 + : project.tabOrder, + }) + .where(eq(projects.id, input.projectId)) + .run(); track("workspace_opened", { - workspace_id: returnedWorkspace.id, + workspace_id: workspace.id, project_id: project.id, type: "branch", - was_existing: wasExisting, + was_existing: false, }); return { - workspace: returnedWorkspace, + workspace, worktreePath: project.mainRepoPath, projectId: project.id, - wasExisting, + wasExisting: false, }; }), @@ -302,7 +378,11 @@ export const createWorkspacesRouter = () => { }), ) .query(async ({ input }) => { - const project = db.data.projects.find((p) => p.id === input.projectId); + const project = localDb + .select() + .from(projects) + .where(eq(projects.id, input.projectId)) + .get(); if (!project) { throw new Error(`Project ${input.projectId} not found`); } @@ -312,9 +392,11 @@ export const createWorkspacesRouter = () => { }); // Get branches that are in use by worktrees, with their workspace IDs - const projectWorkspaces = db.data.workspaces.filter( - (w) => w.projectId === input.projectId, - ); + const projectWorkspaces = localDb + .select() + .from(workspaces) + .where(eq(workspaces.projectId, input.projectId)) + .all(); const worktreeBranchMap: Record = {}; for (const ws of projectWorkspaces) { if (ws.type === "worktree" && ws.branch) { @@ -338,14 +420,25 @@ export const createWorkspacesRouter = () => { }), ) .mutation(async ({ input }) => { - const project = db.data.projects.find((p) => p.id === input.projectId); + const project = localDb + .select() + .from(projects) + .where(eq(projects.id, input.projectId)) + .get(); if (!project) { throw new Error(`Project ${input.projectId} not found`); } - const workspace = db.data.workspaces.find( - (w) => w.projectId === input.projectId && w.type === "branch", - ); + const workspace = localDb + .select() + .from(workspaces) + .where( + and( + eq(workspaces.projectId, input.projectId), + eq(workspaces.type, "branch"), + ), + ) + .get(); if (!workspace) { throw new Error("No branch workspace found for this project"); } @@ -357,20 +450,32 @@ export const createWorkspacesRouter = () => { terminalManager.refreshPromptsForWorkspace(workspace.id); // Update the workspace - name is always the branch for branch workspaces - await db.update((data) => { - const ws = data.workspaces.find((w) => w.id === workspace.id); - if (ws) { - ws.branch = input.branch; - ws.name = input.branch; // Name is always the branch - ws.updatedAt = Date.now(); - ws.lastOpenedAt = Date.now(); - } - data.settings.lastActiveWorkspaceId = workspace.id; - }); - - const updatedWorkspace = db.data.workspaces.find( - (w) => w.id === workspace.id, - ); + const now = Date.now(); + localDb + .update(workspaces) + .set({ + branch: input.branch, + name: input.branch, + updatedAt: now, + lastOpenedAt: now, + }) + .where(eq(workspaces.id, workspace.id)) + .run(); + + localDb + .insert(settings) + .values({ id: 1, lastActiveWorkspaceId: workspace.id }) + .onConflictDoUpdate({ + target: settings.id, + set: { lastActiveWorkspaceId: workspace.id }, + }) + .run(); + + const updatedWorkspace = localDb + .select() + .from(workspaces) + .where(eq(workspaces.id, workspace.id)) + .get(); if (!updatedWorkspace) { throw new Error(`Workspace ${workspace.id} not found after update`); } @@ -384,7 +489,11 @@ export const createWorkspacesRouter = () => { get: publicProcedure .input(z.object({ id: z.string() })) .query(({ input }) => { - const workspace = db.data.workspaces.find((w) => w.id === input.id); + const workspace = localDb + .select() + .from(workspaces) + .where(eq(workspaces.id, input.id)) + .get(); if (!workspace) { throw new Error(`Workspace ${input.id} not found`); } @@ -392,13 +501,19 @@ export const createWorkspacesRouter = () => { }), getAll: publicProcedure.query(() => { - return db.data.workspaces.slice().sort((a, b) => a.tabOrder - b.tabOrder); + return localDb + .select() + .from(workspaces) + .all() + .sort((a, b) => a.tabOrder - b.tabOrder); }), getAllGrouped: publicProcedure.query(() => { - const activeProjects = db.data.projects.filter( - (p) => p.tabOrder !== null, - ); + const activeProjects = localDb + .select() + .from(projects) + .where(isNotNull(projects.tabOrder)) + .all(); const groupsMap = new Map< string, @@ -412,7 +527,7 @@ export const createWorkspacesRouter = () => { workspaces: Array<{ id: string; projectId: string; - worktreeId?: string; + worktreeId: string | null; worktreePath: string; type: "worktree" | "branch"; branch: string; @@ -438,14 +553,17 @@ export const createWorkspacesRouter = () => { }); } - const workspaces = db.data.workspaces - .slice() + const allWorkspaces = localDb + .select() + .from(workspaces) + .all() .sort((a, b) => a.tabOrder - b.tabOrder); - for (const workspace of workspaces) { + for (const workspace of allWorkspaces) { if (groupsMap.has(workspace.projectId)) { groupsMap.get(workspace.projectId)?.workspaces.push({ ...workspace, + type: workspace.type as "worktree" | "branch", worktreePath: getWorkspacePath(workspace) ?? "", }); } @@ -457,26 +575,35 @@ export const createWorkspacesRouter = () => { }), getActive: publicProcedure.query(async () => { - const { lastActiveWorkspaceId } = db.data.settings; + const settingsRow = localDb.select().from(settings).get(); + const lastActiveWorkspaceId = settingsRow?.lastActiveWorkspaceId; if (!lastActiveWorkspaceId) { return null; } - const workspace = db.data.workspaces.find( - (w) => w.id === lastActiveWorkspaceId, - ); + const workspace = localDb + .select() + .from(workspaces) + .where(eq(workspaces.id, lastActiveWorkspaceId)) + .get(); if (!workspace) { throw new Error( `Active workspace ${lastActiveWorkspaceId} not found in database`, ); } - const project = db.data.projects.find( - (p) => p.id === workspace.projectId, - ); + const project = localDb + .select() + .from(projects) + .where(eq(projects.id, workspace.projectId)) + .get(); const worktree = workspace.worktreeId - ? db.data.worktrees.find((wt) => wt.id === workspace.worktreeId) + ? localDb + .select() + .from(worktrees) + .where(eq(worktrees.id, workspace.worktreeId)) + .get() : null; // Detect and persist base branch for existing worktrees that don't have it @@ -497,34 +624,32 @@ export const createWorkspacesRouter = () => { baseBranch = detected; } // Persist the result (detected branch or null sentinel) - await db.update((data) => { - const wt = data.worktrees.find((w) => w.id === worktree.id); - if (wt) { - wt.baseBranch = detected ?? null; - } - }); + localDb + .update(worktrees) + .set({ baseBranch: detected ?? null }) + .where(eq(worktrees.id, worktree.id)) + .run(); } catch { // Detection failed, persist null to avoid retrying - await db.update((data) => { - const wt = data.worktrees.find((w) => w.id === worktree.id); - if (wt) { - wt.baseBranch = null; - } - }); + localDb + .update(worktrees) + .set({ baseBranch: null }) + .where(eq(worktrees.id, worktree.id)) + .run(); } } else { // No remote - persist null to avoid retrying - await db.update((data) => { - const wt = data.worktrees.find((w) => w.id === worktree.id); - if (wt) { - wt.baseBranch = null; - } - }); + localDb + .update(worktrees) + .set({ baseBranch: null }) + .where(eq(worktrees.id, worktree.id)) + .run(); } } return { ...workspace, + type: workspace.type as "worktree" | "branch", worktreePath: getWorkspacePath(workspace) ?? "", project: project ? { @@ -552,20 +677,26 @@ export const createWorkspacesRouter = () => { }), }), ) - .mutation(async ({ input }) => { - await db.update((data) => { - const workspace = data.workspaces.find((w) => w.id === input.id); - if (!workspace) { - throw new Error(`Workspace ${input.id} not found`); - } - - if (input.patch.name !== undefined) { - workspace.name = input.patch.name; - } + .mutation(({ input }) => { + const workspace = localDb + .select() + .from(workspaces) + .where(eq(workspaces.id, input.id)) + .get(); + if (!workspace) { + throw new Error(`Workspace ${input.id} not found`); + } - workspace.updatedAt = Date.now(); - workspace.lastOpenedAt = Date.now(); - }); + const now = Date.now(); + localDb + .update(workspaces) + .set({ + ...(input.patch.name !== undefined && { name: input.patch.name }), + updatedAt: now, + lastOpenedAt: now, + }) + .where(eq(workspaces.id, input.id)) + .run(); return { success: true }; }), @@ -579,7 +710,11 @@ export const createWorkspacesRouter = () => { }), ) .query(async ({ input }) => { - const workspace = db.data.workspaces.find((w) => w.id === input.id); + const workspace = localDb + .select() + .from(workspaces) + .where(eq(workspaces.id, input.id)) + .get(); if (!workspace) { return { @@ -623,11 +758,17 @@ export const createWorkspacesRouter = () => { } const worktree = workspace.worktreeId - ? db.data.worktrees.find((wt) => wt.id === workspace.worktreeId) + ? localDb + .select() + .from(worktrees) + .where(eq(worktrees.id, workspace.worktreeId)) + .get() : null; - const project = db.data.projects.find( - (p) => p.id === workspace.projectId, - ); + const project = localDb + .select() + .from(projects) + .where(eq(projects.id, workspace.projectId)) + .get(); if (worktree && project) { try { @@ -690,7 +831,11 @@ export const createWorkspacesRouter = () => { delete: publicProcedure .input(z.object({ id: z.string() })) .mutation(async ({ input }) => { - const workspace = db.data.workspaces.find((w) => w.id === input.id); + const workspace = localDb + .select() + .from(workspaces) + .where(eq(workspaces.id, input.id)) + .get(); if (!workspace) { return { success: false, error: "Workspace not found" }; @@ -701,18 +846,23 @@ export const createWorkspacesRouter = () => { input.id, ); - const project = db.data.projects.find( - (p) => p.id === workspace.projectId, - ); + const project = localDb + .select() + .from(projects) + .where(eq(projects.id, workspace.projectId)) + .get(); let teardownError: string | undefined; - let worktree: (typeof db.data.worktrees)[0] | undefined; + let worktree: SelectWorktree | undefined; // Branch workspaces don't have worktrees - skip worktree operations if (workspace.type === "worktree" && workspace.worktreeId) { - worktree = db.data.worktrees.find( - (wt) => wt.id === workspace.worktreeId, - ); + worktree = + localDb + .select() + .from(worktrees) + .where(eq(worktrees.id, workspace.worktreeId)) + .get() ?? undefined; if (worktree && project) { // Run teardown scripts before removing worktree @@ -753,34 +903,44 @@ export const createWorkspacesRouter = () => { } // Proceed with DB cleanup - await db.update((data) => { - data.workspaces = data.workspaces.filter((w) => w.id !== input.id); + localDb.delete(workspaces).where(eq(workspaces.id, input.id)).run(); - if (worktree) { - data.worktrees = data.worktrees.filter( - (wt) => wt.id !== worktree.id, - ); - } + if (worktree) { + localDb.delete(worktrees).where(eq(worktrees.id, worktree.id)).run(); + } - if (project) { - const remainingWorkspaces = data.workspaces.filter( - (w) => w.projectId === workspace.projectId, - ); - if (remainingWorkspaces.length === 0) { - const p = data.projects.find((p) => p.id === workspace.projectId); - if (p) { - p.tabOrder = null; - } - } + if (project) { + const remainingWorkspaces = localDb + .select() + .from(workspaces) + .where(eq(workspaces.projectId, workspace.projectId)) + .all(); + if (remainingWorkspaces.length === 0) { + localDb + .update(projects) + .set({ tabOrder: null }) + .where(eq(projects.id, workspace.projectId)) + .run(); } + } - 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; - } - }); + const settingsRow = localDb.select().from(settings).get(); + if (settingsRow?.lastActiveWorkspaceId === input.id) { + const sorted = localDb + .select() + .from(workspaces) + .orderBy(desc(workspaces.lastOpenedAt)) + .all(); + const newActiveId = sorted[0]?.id ?? null; + localDb + .insert(settings) + .values({ id: 1, lastActiveWorkspaceId: newActiveId }) + .onConflictDoUpdate({ + target: settings.id, + set: { lastActiveWorkspaceId: newActiveId }, + }) + .run(); + } const terminalWarning = terminalResult.failed > 0 @@ -794,17 +954,31 @@ export const createWorkspacesRouter = () => { setActive: publicProcedure .input(z.object({ id: z.string() })) - .mutation(async ({ input }) => { - await db.update((data) => { - const workspace = data.workspaces.find((w) => w.id === input.id); - if (!workspace) { - throw new Error(`Workspace ${input.id} not found`); - } + .mutation(({ input }) => { + const workspace = localDb + .select() + .from(workspaces) + .where(eq(workspaces.id, input.id)) + .get(); + if (!workspace) { + throw new Error(`Workspace ${input.id} not found`); + } - data.settings.lastActiveWorkspaceId = input.id; - workspace.lastOpenedAt = Date.now(); - workspace.updatedAt = Date.now(); - }); + const now = Date.now(); + localDb + .update(workspaces) + .set({ lastOpenedAt: now, updatedAt: now }) + .where(eq(workspaces.id, input.id)) + .run(); + + localDb + .insert(settings) + .values({ id: 1, lastActiveWorkspaceId: input.id }) + .onConflictDoUpdate({ + target: settings.id, + set: { lastActiveWorkspaceId: input.id }, + }) + .run(); return { success: true }; }), @@ -817,33 +991,35 @@ export const createWorkspacesRouter = () => { toIndex: z.number(), }), ) - .mutation(async ({ input }) => { - await db.update((data) => { - const { projectId, fromIndex, toIndex } = input; - - const projectWorkspaces = data.workspaces - .filter((w) => w.projectId === projectId) - .sort((a, b) => a.tabOrder - b.tabOrder); - - if ( - fromIndex < 0 || - fromIndex >= projectWorkspaces.length || - toIndex < 0 || - toIndex >= projectWorkspaces.length - ) { - throw new Error("Invalid fromIndex or toIndex"); - } + .mutation(({ input }) => { + const { projectId, fromIndex, toIndex } = input; + + const projectWorkspaces = localDb + .select() + .from(workspaces) + .where(eq(workspaces.projectId, projectId)) + .all() + .sort((a, b) => a.tabOrder - b.tabOrder); + + if ( + fromIndex < 0 || + fromIndex >= projectWorkspaces.length || + toIndex < 0 || + toIndex >= projectWorkspaces.length + ) { + throw new Error("Invalid fromIndex or toIndex"); + } - const [removed] = projectWorkspaces.splice(fromIndex, 1); - projectWorkspaces.splice(toIndex, 0, removed); + const [removed] = projectWorkspaces.splice(fromIndex, 1); + projectWorkspaces.splice(toIndex, 0, removed); - projectWorkspaces.forEach((workspace, index) => { - const ws = data.workspaces.find((w) => w.id === workspace.id); - if (ws) { - ws.tabOrder = index; - } - }); - }); + for (let i = 0; i < projectWorkspaces.length; i++) { + localDb + .update(workspaces) + .set({ tabOrder: i }) + .where(eq(workspaces.id, projectWorkspaces[i].id)) + .run(); + } return { success: true }; }), @@ -851,25 +1027,33 @@ export const createWorkspacesRouter = () => { refreshGitStatus: publicProcedure .input(z.object({ workspaceId: z.string() })) .mutation(async ({ input }) => { - const workspace = db.data.workspaces.find( - (w) => w.id === input.workspaceId, - ); + const workspace = localDb + .select() + .from(workspaces) + .where(eq(workspaces.id, input.workspaceId)) + .get(); if (!workspace) { throw new Error(`Workspace ${input.workspaceId} not found`); } - const worktree = db.data.worktrees.find( - (wt) => wt.id === workspace.worktreeId, - ); + const worktree = workspace.worktreeId + ? localDb + .select() + .from(worktrees) + .where(eq(worktrees.id, workspace.worktreeId)) + .get() + : null; if (!worktree) { throw new Error( `Worktree for workspace ${input.workspaceId} not found`, ); } - const project = db.data.projects.find( - (p) => p.id === workspace.projectId, - ); + const project = localDb + .select() + .from(projects) + .where(eq(projects.id, workspace.projectId)) + .get(); if (!project) { throw new Error(`Project ${workspace.projectId} not found`); } @@ -879,10 +1063,11 @@ export const createWorkspacesRouter = () => { if (!defaultBranch) { defaultBranch = await getDefaultBranch(project.mainRepoPath); // Save it for future use - await db.update((data) => { - const p = data.projects.find((p) => p.id === project.id); - if (p) p.defaultBranch = defaultBranch; - }); + localDb + .update(projects) + .set({ defaultBranch }) + .where(eq(projects.id, project.id)) + .run(); } // Fetch default branch to get latest @@ -901,12 +1086,11 @@ export const createWorkspacesRouter = () => { }; // Update worktree in db - await db.update((data) => { - const wt = data.worktrees.find((w) => w.id === worktree.id); - if (wt) { - wt.gitStatus = gitStatus; - } - }); + localDb + .update(worktrees) + .set({ gitStatus }) + .where(eq(worktrees.id, worktree.id)) + .run(); return { gitStatus }; }), @@ -914,16 +1098,22 @@ export const createWorkspacesRouter = () => { getGitHubStatus: publicProcedure .input(z.object({ workspaceId: z.string() })) .query(async ({ input }) => { - const workspace = db.data.workspaces.find( - (w) => w.id === input.workspaceId, - ); + const workspace = localDb + .select() + .from(workspaces) + .where(eq(workspaces.id, input.workspaceId)) + .get(); if (!workspace) { return null; } - const worktree = db.data.worktrees.find( - (wt) => wt.id === workspace.worktreeId, - ); + const worktree = workspace.worktreeId + ? localDb + .select() + .from(worktrees) + .where(eq(worktrees.id, workspace.worktreeId)) + .get() + : null; if (!worktree) { return null; } @@ -933,12 +1123,11 @@ export const createWorkspacesRouter = () => { // Update cache if we got data if (freshStatus) { - await db.update((data) => { - const wt = data.worktrees.find((w) => w.id === worktree.id); - if (wt) { - wt.githubStatus = freshStatus; - } - }); + localDb + .update(worktrees) + .set({ githubStatus: freshStatus }) + .where(eq(worktrees.id, worktree.id)) + .run(); } return freshStatus; @@ -947,16 +1136,22 @@ export const createWorkspacesRouter = () => { getWorktreeInfo: publicProcedure .input(z.object({ workspaceId: z.string() })) .query(({ input }) => { - const workspace = db.data.workspaces.find( - (w) => w.id === input.workspaceId, - ); + const workspace = localDb + .select() + .from(workspaces) + .where(eq(workspaces.id, input.workspaceId)) + .get(); if (!workspace) { return null; } - const worktree = db.data.worktrees.find( - (wt) => wt.id === workspace.worktreeId, - ); + const worktree = workspace.worktreeId + ? localDb + .select() + .from(worktrees) + .where(eq(worktrees.id, workspace.worktreeId)) + .get() + : null; if (!worktree) { return null; } @@ -975,14 +1170,18 @@ export const createWorkspacesRouter = () => { getWorktreesByProject: publicProcedure .input(z.object({ projectId: z.string() })) .query(({ input }) => { - const worktrees = db.data.worktrees.filter( - (wt) => wt.projectId === input.projectId, - ); - - return worktrees.map((wt) => { - const workspace = db.data.workspaces.find( - (w) => w.worktreeId === wt.id, - ); + const projectWorktrees = localDb + .select() + .from(worktrees) + .where(eq(worktrees.projectId, input.projectId)) + .all(); + + return projectWorktrees.map((wt) => { + const workspace = localDb + .select() + .from(workspaces) + .where(eq(workspaces.worktreeId, wt.id)) + .get(); return { ...wt, hasActiveWorkspace: workspace !== undefined, @@ -999,24 +1198,30 @@ export const createWorkspacesRouter = () => { }), ) .mutation(async ({ input }) => { - const worktree = db.data.worktrees.find( - (wt) => wt.id === input.worktreeId, - ); + const worktree = localDb + .select() + .from(worktrees) + .where(eq(worktrees.id, input.worktreeId)) + .get(); if (!worktree) { throw new Error(`Worktree ${input.worktreeId} not found`); } // Check if worktree already has an active workspace - const existingWorkspace = db.data.workspaces.find( - (w) => w.worktreeId === input.worktreeId, - ); + const existingWorkspace = localDb + .select() + .from(workspaces) + .where(eq(workspaces.worktreeId, input.worktreeId)) + .get(); if (existingWorkspace) { throw new Error("Worktree already has an active workspace"); } - const project = db.data.projects.find( - (p) => p.id === worktree.projectId, - ); + const project = localDb + .select() + .from(projects) + .where(eq(projects.id, worktree.projectId)) + .get(); if (!project) { throw new Error(`Project ${worktree.projectId} not found`); } @@ -1030,48 +1235,62 @@ export const createWorkspacesRouter = () => { throw new Error("Worktree no longer exists on disk"); } - const projectWorkspaces = db.data.workspaces.filter( - (w) => w.projectId === worktree.projectId, - ); + const projectWorkspaces = localDb + .select() + .from(workspaces) + .where(eq(workspaces.projectId, worktree.projectId)) + .all(); const maxTabOrder = projectWorkspaces.length > 0 ? Math.max(...projectWorkspaces.map((w) => w.tabOrder)) : -1; - const workspace = { - id: nanoid(), - projectId: worktree.projectId, - worktreeId: worktree.id, - type: "worktree" as const, - branch: worktree.branch, - name: input.name ?? worktree.branch, - tabOrder: maxTabOrder + 1, - createdAt: Date.now(), - updatedAt: Date.now(), - lastOpenedAt: Date.now(), - }; - - await db.update((data) => { - data.workspaces.push(workspace); - data.settings.lastActiveWorkspaceId = workspace.id; + // Insert workspace + const workspace = localDb + .insert(workspaces) + .values({ + projectId: worktree.projectId, + worktreeId: worktree.id, + type: "worktree", + branch: worktree.branch, + name: input.name ?? worktree.branch, + tabOrder: maxTabOrder + 1, + }) + .returning() + .get(); + + // Update settings + localDb + .insert(settings) + .values({ id: 1, lastActiveWorkspaceId: workspace.id }) + .onConflictDoUpdate({ + target: settings.id, + set: { lastActiveWorkspaceId: workspace.id }, + }) + .run(); + + // Update project + const activeProjects = localDb + .select() + .from(projects) + .where(isNotNull(projects.tabOrder)) + .all(); + const maxProjectTabOrder = + activeProjects.length > 0 + ? Math.max(...activeProjects.map((p) => p.tabOrder ?? 0)) + : -1; - const p = data.projects.find((p) => p.id === worktree.projectId); - if (p) { - p.lastOpenedAt = Date.now(); - - if (p.tabOrder === null) { - const activeProjects = data.projects.filter( - (proj) => proj.tabOrder !== null, - ); - const maxProjectTabOrder = - activeProjects.length > 0 - ? // biome-ignore lint/style/noNonNullAssertion: filter guarantees tabOrder is not null - Math.max(...activeProjects.map((proj) => proj.tabOrder!)) - : -1; - p.tabOrder = maxProjectTabOrder + 1; - } - } - }); + localDb + .update(projects) + .set({ + lastOpenedAt: Date.now(), + tabOrder: + project.tabOrder === null + ? maxProjectTabOrder + 1 + : project.tabOrder, + }) + .where(eq(projects.id, worktree.projectId)) + .run(); // Load setup configuration from the main repo const setupConfig = loadSetupConfig(project.mainRepoPath); @@ -1093,7 +1312,11 @@ export const createWorkspacesRouter = () => { close: publicProcedure .input(z.object({ id: z.string() })) .mutation(async ({ input }) => { - const workspace = db.data.workspaces.find((w) => w.id === input.id); + const workspace = localDb + .select() + .from(workspaces) + .where(eq(workspaces.id, input.id)) + .get(); if (!workspace) { throw new Error("Workspace not found"); @@ -1105,28 +1328,40 @@ export const createWorkspacesRouter = () => { ); // Delete workspace record ONLY, keep worktree - await db.update((data) => { - data.workspaces = data.workspaces.filter((w) => w.id !== input.id); - - // Check if project should be hidden (no more open workspaces) - const remainingWorkspaces = data.workspaces.filter( - (w) => w.projectId === workspace.projectId, - ); - if (remainingWorkspaces.length === 0) { - const p = data.projects.find((p) => p.id === workspace.projectId); - if (p) { - p.tabOrder = null; - } - } + localDb.delete(workspaces).where(eq(workspaces.id, input.id)).run(); + + // Check if project should be hidden (no more open workspaces) + const remainingWorkspaces = localDb + .select() + .from(workspaces) + .where(eq(workspaces.projectId, workspace.projectId)) + .all(); + if (remainingWorkspaces.length === 0) { + localDb + .update(projects) + .set({ tabOrder: null }) + .where(eq(projects.id, workspace.projectId)) + .run(); + } - // Update active workspace if this was the active one - 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; - } - }); + // Update active workspace if this was the active one + const settingsRow = localDb.select().from(settings).get(); + if (settingsRow?.lastActiveWorkspaceId === input.id) { + const sorted = localDb + .select() + .from(workspaces) + .orderBy(desc(workspaces.lastOpenedAt)) + .all(); + const newActiveId = sorted[0]?.id ?? null; + localDb + .insert(settings) + .values({ id: 1, lastActiveWorkspaceId: newActiveId }) + .onConflictDoUpdate({ + target: settings.id, + set: { lastActiveWorkspaceId: newActiveId }, + }) + .run(); + } const terminalWarning = terminalResult.failed > 0 diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index a5b3acda691..1964f854d7c 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -7,10 +7,13 @@ import { shutdown as shutdownAnalytics, track } from "./lib/analytics"; import { initAppState } from "./lib/app-state"; import { authService, handleAuthDeepLink, isAuthDeepLink } from "./lib/auth"; import { setupAutoUpdater } from "./lib/auto-updater"; -import { initDb } from "./lib/db"; +import { localDb } from "./lib/local-db"; import { terminalManager } from "./lib/terminal"; import { MainWindow } from "./windows/main"; +// Initialize local SQLite database (runs migrations + legacy data migration on import) +console.log("[main] Local database ready:", !!localDb); + // Set different app name for dev to avoid singleton lock conflicts with production if (process.env.NODE_ENV === "development") { app.setName("Superset Dev"); @@ -120,7 +123,6 @@ if (!gotTheLock) { (async () => { await app.whenReady(); - await initDb(); await initAppState(); await authService.initialize(); diff --git a/apps/desktop/src/main/lib/db/index.ts b/apps/desktop/src/main/lib/db/index.ts deleted file mode 100644 index c3c8c60ef8c..00000000000 --- a/apps/desktop/src/main/lib/db/index.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { JSONFilePreset } from "lowdb/node"; -import { DB_PATH } from "../app-environment"; -import type { Database, Workspace } from "./schemas"; -import { defaultDatabase } from "./schemas"; - -type DB = Awaited>>; - -let _db: DB | null = null; - -/** - * Migrate existing workspaces to include type and branch fields. - * Existing workspaces are all worktree-based. - */ -async function migrateWorkspaces(database: DB): Promise { - let needsWrite = false; - - for (const workspace of database.data.workspaces) { - // Cast to allow checking for missing fields - const ws = workspace as Workspace & { type?: string; branch?: string }; - - // Add type field if missing (existing workspaces are all worktree type) - if (!ws.type) { - ws.type = "worktree"; - needsWrite = true; - } - - // Add branch field if missing (copy from associated worktree) - if (!ws.branch) { - if (ws.worktreeId) { - const worktree = database.data.worktrees.find( - (wt) => wt.id === ws.worktreeId, - ); - if (worktree) { - ws.branch = worktree.branch; - } else { - console.warn( - `Migration: Worktree ${ws.worktreeId} not found for workspace ${ws.id}, using fallback branch`, - ); - ws.branch = "unknown"; - } - } else { - // Workspace without worktreeId (shouldn't happen for existing data, but be safe) - console.warn( - `Migration: Workspace ${ws.id} has no worktreeId, using fallback branch`, - ); - ws.branch = "unknown"; - } - needsWrite = true; - } - } - - if (needsWrite) { - await database.write(); - console.log("Migrated workspaces to include type and branch fields"); - } -} - -export async function initDb(): Promise { - if (_db) return; - - const dbPath = DB_PATH; - _db = await JSONFilePreset(dbPath, defaultDatabase); - console.log(`Database initialized at: ${dbPath}`); - - // Run migrations - await migrateWorkspaces(_db); -} - -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 deleted file mode 100644 index 3a3cb061146..00000000000 --- a/apps/desktop/src/main/lib/db/schemas.ts +++ /dev/null @@ -1,132 +0,0 @@ -export interface Project { - id: string; - mainRepoPath: string; - name: string; - color: string; - tabOrder: number | null; - lastOpenedAt: number; - createdAt: number; - configToastDismissed?: boolean; - defaultBranch?: string; // Detected default branch (e.g., 'main', 'master') -} - -export interface GitStatus { - branch: string; - needsRebase: boolean; - lastRefreshed: number; -} - -export interface CheckItem { - name: string; - status: "success" | "failure" | "pending" | "skipped" | "cancelled"; - url?: string; -} - -export interface GitHubStatus { - pr: { - number: number; - title: string; - url: string; - state: "open" | "draft" | "merged" | "closed"; - mergedAt?: number; - additions: number; - deletions: number; - reviewDecision: "approved" | "changes_requested" | "pending"; - checksStatus: "success" | "failure" | "pending" | "none"; - checks: CheckItem[]; - } | null; - repoUrl: string; - branchExistsOnRemote: boolean; - lastRefreshed: number; -} - -export interface Worktree { - id: string; - projectId: string; - path: string; - branch: string; - baseBranch?: string | null; // The branch this worktree was created from (null = detection attempted, not found) - createdAt: number; - gitStatus?: GitStatus; - githubStatus?: GitHubStatus; -} - -export type WorkspaceType = "worktree" | "branch"; - -export interface Workspace { - id: string; - projectId: string; - worktreeId?: string; // Only set for type="worktree" - type: WorkspaceType; - branch: string; // Branch name for both types - name: string; - tabOrder: number; - createdAt: number; - updatedAt: number; - lastOpenedAt: number; -} - -export interface Tab { - id: string; - title: string; - terminalId?: string; - type: "single" | "group"; - createdAt: number; - updatedAt: number; -} - -export const EXTERNAL_APPS = [ - "finder", - "vscode", - "cursor", - "sublime", - "xcode", - "iterm", - "warp", - "terminal", - // JetBrains IDEs - "intellij", - "webstorm", - "pycharm", - "phpstorm", - "rubymine", - "goland", - "clion", - "rider", - "datagrip", - "appcode", - "fleet", - "rustrover", -] as const; - -export type ExternalApp = (typeof EXTERNAL_APPS)[number]; - -export interface TerminalPreset { - id: string; - name: string; - description?: string; - cwd: string; - commands: string[]; -} - -export interface Settings { - lastActiveWorkspaceId?: string; - lastUsedApp?: ExternalApp; - terminalPresets?: TerminalPreset[]; - terminalPresetsInitialized?: boolean; - selectedRingtoneId?: string; -} - -export interface Database { - projects: Project[]; - worktrees: Worktree[]; - workspaces: Workspace[]; - settings: Settings; -} - -export const defaultDatabase: Database = { - projects: [], - worktrees: [], - workspaces: [], - settings: {}, -}; diff --git a/apps/desktop/src/main/lib/local-db/index.ts b/apps/desktop/src/main/lib/local-db/index.ts new file mode 100644 index 00000000000..c7e74a5babc --- /dev/null +++ b/apps/desktop/src/main/lib/local-db/index.ts @@ -0,0 +1,218 @@ +import { existsSync, readFileSync, renameSync } from "node:fs"; +import { join } from "node:path"; +import * as schema from "@superset/local-db"; +import { projects, settings, workspaces, worktrees } from "@superset/local-db"; +import Database from "better-sqlite3"; +import { count } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/better-sqlite3"; +import { migrate } from "drizzle-orm/better-sqlite3/migrator"; +import { app } from "electron"; +import { env } from "../../env.main"; +import { + DB_PATH as LEGACY_DB_PATH, + SUPERSET_HOME_DIR, +} from "../app-environment"; + +const DB_PATH = join(SUPERSET_HOME_DIR, "local.db"); + +/** + * Gets the migrations directory path. + * + * Path resolution strategy: + * - Production (packaged .app): resources/migrations/ + * - Development (NODE_ENV=development): packages/local-db/drizzle/ + * - Preview (electron-vite preview): dist/resources/migrations/ + * - Test environment: Use monorepo path relative to __dirname + */ +function getMigrationsDirectory(): string { + // Check if running in Electron (app.getAppPath exists) + const isElectron = + typeof app?.getAppPath === "function" && + typeof app?.isPackaged === "boolean"; + + if (isElectron && app.isPackaged) { + return join(process.resourcesPath, "resources/migrations"); + } + + const isDev = env.NODE_ENV === "development"; + + if (isElectron && isDev) { + // Development: source files in monorepo + return join(app.getAppPath(), "../../packages/local-db/drizzle"); + } + + // Preview mode or test: __dirname is dist/main, so go up one level to dist/resources/migrations + const previewPath = join(__dirname, "../resources/migrations"); + if (existsSync(previewPath)) { + return previewPath; + } + + // Fallback: try monorepo path (for tests or dev without Electron) + // From apps/desktop/src/main/lib/local-db -> packages/local-db/drizzle + const monorepoPath = join( + __dirname, + "../../../../../packages/local-db/drizzle", + ); + if (existsSync(monorepoPath)) { + return monorepoPath; + } + + // Try Electron app path if available + if (isElectron) { + const srcPath = join(app.getAppPath(), "../../packages/local-db/drizzle"); + if (existsSync(srcPath)) { + return srcPath; + } + } + + console.warn(`[local-db] Migrations directory not found at: ${previewPath}`); + return previewPath; +} + +const migrationsFolder = getMigrationsDirectory(); + +const sqlite = new Database(DB_PATH); +sqlite.pragma("journal_mode = WAL"); + +console.log(`[local-db] Database initialized at: ${DB_PATH}`); +console.log(`[local-db] Running migrations from: ${migrationsFolder}`); + +export const localDb = drizzle(sqlite, { schema }); + +migrate(localDb, { migrationsFolder }); + +console.log("[local-db] Migrations complete"); + +/** + * Migrate data from legacy db.json (lowdb) to SQLite. + * Only runs if: + * 1. db.json exists + * 2. SQLite projects table is empty (first run after migration) + */ +function migrateFromLegacyDb(): void { + if (!existsSync(LEGACY_DB_PATH)) { + return; + } + + // Check if SQLite is empty + const projectCount = localDb.select({ count: count() }).from(projects).get(); + if (projectCount && projectCount.count > 0) { + console.log( + "[local-db] SQLite already has data, skipping legacy migration", + ); + return; + } + + console.log("[local-db] Migrating data from legacy db.json..."); + + try { + const legacyData = JSON.parse(readFileSync(LEGACY_DB_PATH, "utf-8")); + + // Migrate projects + if (legacyData.projects?.length > 0) { + for (const p of legacyData.projects) { + localDb + .insert(projects) + .values({ + id: p.id, + mainRepoPath: p.mainRepoPath, + name: p.name, + color: p.color, + tabOrder: p.tabOrder, + lastOpenedAt: p.lastOpenedAt, + createdAt: p.createdAt, + configToastDismissed: p.configToastDismissed, + defaultBranch: p.defaultBranch, + }) + .run(); + } + console.log(`[local-db] Migrated ${legacyData.projects.length} projects`); + } + + // Migrate worktrees + if (legacyData.worktrees?.length > 0) { + for (const w of legacyData.worktrees) { + localDb + .insert(worktrees) + .values({ + id: w.id, + projectId: w.projectId, + path: w.path, + branch: w.branch, + baseBranch: w.baseBranch, + createdAt: w.createdAt, + gitStatus: w.gitStatus, + githubStatus: w.githubStatus, + }) + .run(); + } + console.log( + `[local-db] Migrated ${legacyData.worktrees.length} worktrees`, + ); + } + + // Migrate workspaces + if (legacyData.workspaces?.length > 0) { + for (const ws of legacyData.workspaces) { + // Get branch from worktree if not set on workspace + let branch = ws.branch; + if (!branch && ws.worktreeId) { + const worktree = legacyData.worktrees?.find( + (wt: { id: string }) => wt.id === ws.worktreeId, + ); + branch = worktree?.branch ?? "unknown"; + } + + localDb + .insert(workspaces) + .values({ + id: ws.id, + projectId: ws.projectId, + worktreeId: ws.worktreeId, + type: ws.type ?? "worktree", // Default to worktree for legacy data + branch: branch ?? "unknown", + name: ws.name, + tabOrder: ws.tabOrder, + createdAt: ws.createdAt, + updatedAt: ws.updatedAt, + lastOpenedAt: ws.lastOpenedAt, + }) + .run(); + } + console.log( + `[local-db] Migrated ${legacyData.workspaces.length} workspaces`, + ); + } + + // Migrate settings + if (legacyData.settings) { + const s = legacyData.settings; + localDb + .insert(settings) + .values({ + id: 1, + lastActiveWorkspaceId: s.lastActiveWorkspaceId, + lastUsedApp: s.lastUsedApp, + terminalPresets: s.terminalPresets, + terminalPresetsInitialized: s.terminalPresetsInitialized, + selectedRingtoneId: s.selectedRingtoneId, + }) + .run(); + console.log("[local-db] Migrated settings"); + } + + // Backup the legacy db.json + const backupPath = `${LEGACY_DB_PATH}.backup`; + renameSync(LEGACY_DB_PATH, backupPath); + console.log(`[local-db] Legacy db.json backed up to ${backupPath}`); + + console.log("[local-db] Legacy migration complete!"); + } catch (error) { + console.error("[local-db] Failed to migrate legacy data:", error); + // Don't throw - app can continue with empty db + } +} + +migrateFromLegacyDb(); + +export type LocalDb = typeof localDb; diff --git a/apps/desktop/src/main/lib/notification-sound.ts b/apps/desktop/src/main/lib/notification-sound.ts index a8d1f5faf1f..5577cba658d 100644 --- a/apps/desktop/src/main/lib/notification-sound.ts +++ b/apps/desktop/src/main/lib/notification-sound.ts @@ -1,10 +1,11 @@ import { execFile } from "node:child_process"; import { existsSync } from "node:fs"; +import { settings } from "@superset/local-db"; import { DEFAULT_RINGTONE_ID, getRingtoneFilename, } from "../../shared/ringtones"; -import { db } from "./db"; +import { localDb } from "./local-db"; import { getSoundPath } from "./sound-paths"; /** @@ -15,8 +16,8 @@ function getSelectedRingtoneFilename(): string { const defaultFilename = getRingtoneFilename(DEFAULT_RINGTONE_ID); try { - const selectedId = - db.data.settings.selectedRingtoneId ?? DEFAULT_RINGTONE_ID; + const settingsRow = localDb.select().from(settings).get(); + const selectedId = settingsRow?.selectedRingtoneId ?? DEFAULT_RINGTONE_ID; // "none" means silent - return empty string intentionally if (selectedId === "none") { diff --git a/apps/desktop/src/main/windows/main.ts b/apps/desktop/src/main/windows/main.ts index 7fded0d927b..65161e62783 100644 --- a/apps/desktop/src/main/windows/main.ts +++ b/apps/desktop/src/main/windows/main.ts @@ -1,14 +1,16 @@ import { join } from "node:path"; +import { workspaces, worktrees } from "@superset/local-db"; +import { eq } from "drizzle-orm"; import type { BrowserWindow } from "electron"; import { Notification, screen } from "electron"; import { createWindow } from "lib/electron-app/factories/windows/create"; import { createAppRouter } from "lib/trpc/routers"; +import { localDb } from "main/lib/local-db"; import { NOTIFICATION_EVENTS, PORTS } from "shared/constants"; import { createIPCHandler } from "trpc-electron/main"; import { productName } from "~/package.json"; import { appState } from "../lib/app-state"; import { setMainWindow } from "../lib/auto-updater"; -import { db } from "../lib/db"; import { createApplicationMenu } from "../lib/menu"; import { playNotificationSound } from "../lib/notification-sound"; import { @@ -87,14 +89,18 @@ export async function MainWindow() { // Derive workspace name from workspaceId with safe fallbacks let workspaceName = "Workspace"; try { - const workspaces = db.data?.workspaces; - const worktrees = db.data?.worktrees; - if (Array.isArray(workspaces) && Array.isArray(worktrees)) { - const workspace = workspaces.find( - (w) => w.id === event.workspaceId, - ); - const worktree = workspace - ? worktrees.find((wt) => wt.id === workspace.worktreeId) + if (event.workspaceId) { + const workspace = localDb + .select() + .from(workspaces) + .where(eq(workspaces.id, event.workspaceId)) + .get(); + const worktree = workspace?.worktreeId + ? localDb + .select() + .from(worktrees) + .where(eq(worktrees.id, workspace.worktreeId)) + .get() : undefined; workspaceName = workspace?.name || worktree?.branch || "Workspace"; } diff --git a/apps/desktop/src/renderer/components/OpenInButton/OpenInButton.tsx b/apps/desktop/src/renderer/components/OpenInButton/OpenInButton.tsx index 0d65eb6d135..655ae320a33 100644 --- a/apps/desktop/src/renderer/components/OpenInButton/OpenInButton.tsx +++ b/apps/desktop/src/renderer/components/OpenInButton/OpenInButton.tsx @@ -1,3 +1,4 @@ +import type { ExternalApp } from "@superset/local-db"; import { Button } from "@superset/ui/button"; import { ButtonGroup } from "@superset/ui/button-group"; import { @@ -11,7 +12,6 @@ import { DropdownMenuTrigger, } from "@superset/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import type { ExternalApp } from "main/lib/db/schemas"; import { useState } from "react"; import { HiChevronDown } from "react-icons/hi2"; import { LuCopy } from "react-icons/lu"; diff --git a/apps/desktop/src/renderer/screens/main/components/SettingsView/PresetsSettings/types.ts b/apps/desktop/src/renderer/screens/main/components/SettingsView/PresetsSettings/types.ts index cad62567d79..df42000bffd 100644 --- a/apps/desktop/src/renderer/screens/main/components/SettingsView/PresetsSettings/types.ts +++ b/apps/desktop/src/renderer/screens/main/components/SettingsView/PresetsSettings/types.ts @@ -1,4 +1,4 @@ -import type { TerminalPreset } from "main/lib/db/schemas"; +import type { TerminalPreset } from "@superset/local-db"; export type { TerminalPreset }; diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/ChecksList.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/ChecksList.tsx index 16e5f2c2d8d..017de3e5a97 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/ChecksList.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/ChecksList.tsx @@ -1,4 +1,4 @@ -import type { CheckItem } from "main/lib/db/schemas"; +import type { CheckItem } from "@superset/local-db"; import { useState } from "react"; import { LuChevronDown, LuChevronRight } from "react-icons/lu"; import { CheckItemRow } from "./components/CheckItemRow"; diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/CheckItemRow.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/CheckItemRow.tsx index 401db80f455..3dca021707c 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/CheckItemRow.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksList/components/CheckItemRow/CheckItemRow.tsx @@ -1,4 +1,4 @@ -import type { CheckItem } from "main/lib/db/schemas"; +import type { CheckItem } from "@superset/local-db"; import { LuCheck, LuLoaderCircle, LuMinus, LuX } from "react-icons/lu"; interface CheckItemRowProps { diff --git a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksSummary/ChecksSummary.tsx b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksSummary/ChecksSummary.tsx index 14238bb0d73..bbcc2718fcb 100644 --- a/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksSummary/ChecksSummary.tsx +++ b/apps/desktop/src/renderer/screens/main/components/TopBar/WorkspaceTabs/WorkspaceHoverCard/components/ChecksSummary/ChecksSummary.tsx @@ -1,4 +1,4 @@ -import type { CheckItem } from "main/lib/db/schemas"; +import type { CheckItem } from "@superset/local-db"; import { LuCheck, LuLoaderCircle, LuX } from "react-icons/lu"; interface ChecksSummaryProps { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabsCommandDialog/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabsCommandDialog/index.tsx index b6a3ea2ab36..63ee4320e2c 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabsCommandDialog/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/TabsCommandDialog/index.tsx @@ -1,3 +1,4 @@ +import type { TerminalPreset } from "@superset/local-db"; import { CommandDialog, CommandEmpty, @@ -6,7 +7,6 @@ import { CommandItem, CommandList, } from "@superset/ui/command"; -import type { TerminalPreset } from "main/lib/db/schemas"; import { HiMiniCommandLine, HiMiniPlus, diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/index.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/index.tsx index 97091068372..2baa293058b 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/index.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/Sidebar/TabsView/index.tsx @@ -1,7 +1,7 @@ +import type { TerminalPreset } from "@superset/local-db"; import { Button } from "@superset/ui/button"; import { ButtonGroup } from "@superset/ui/button-group"; import { LayoutGroup, motion } from "framer-motion"; -import type { TerminalPreset } from "main/lib/db/schemas"; import { useMemo, useRef, useState } from "react"; import { useDrop } from "react-dnd"; import { diff --git a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceFooter/components/WorkspaceFooterRight/WorkspaceFooterRight.tsx b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceFooter/components/WorkspaceFooterRight/WorkspaceFooterRight.tsx index dd130182767..8cae7d0d51e 100644 --- a/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceFooter/components/WorkspaceFooterRight/WorkspaceFooterRight.tsx +++ b/apps/desktop/src/renderer/screens/main/components/WorkspaceView/WorkspaceFooter/components/WorkspaceFooterRight/WorkspaceFooterRight.tsx @@ -1,3 +1,4 @@ +import type { ExternalApp } from "@superset/local-db"; import { DropdownMenu, DropdownMenuContent, @@ -10,7 +11,6 @@ import { DropdownMenuTrigger, } from "@superset/ui/dropdown-menu"; import { Tooltip, TooltipContent, TooltipTrigger } from "@superset/ui/tooltip"; -import type { ExternalApp } from "main/lib/db/schemas"; import { HiChevronDown } from "react-icons/hi2"; import { LuArrowUpRight, LuCopy } from "react-icons/lu"; import jetbrainsIcon from "renderer/assets/app-icons/jetbrains.svg"; diff --git a/apps/desktop/test-setup.ts b/apps/desktop/test-setup.ts index bf3571da16a..d3f666f4431 100644 --- a/apps/desktop/test-setup.ts +++ b/apps/desktop/test-setup.ts @@ -67,6 +67,8 @@ mock.module("electron", () => ({ getPath: mock(() => testTmpDir), getName: mock(() => "test-app"), getVersion: mock(() => "1.0.0"), + getAppPath: mock(() => testTmpDir), + isPackaged: false, }, dialog: { showOpenDialog: mock(() => @@ -88,6 +90,24 @@ mock.module("electron", () => ({ }, shell: { openExternal: mock(() => Promise.resolve()), + openPath: mock(() => Promise.resolve("")), + }, + clipboard: { + writeText: mock(), + readText: mock(() => ""), + }, + screen: { + getPrimaryDisplay: mock(() => ({ + workAreaSize: { width: 1920, height: 1080 }, + })), + }, + Notification: mock(() => ({ + show: mock(), + on: mock(), + })), + Menu: { + buildFromTemplate: mock(() => ({})), + setApplicationMenu: mock(), }, })); @@ -100,3 +120,48 @@ mock.module("main/lib/analytics", () => ({ clearUserCache: mock(() => {}), shutdown: mock(() => Promise.resolve()), })); + +// ============================================================================= +// Local DB Mock (better-sqlite3 not supported in Bun tests) +// ============================================================================= + +mock.module("main/lib/local-db", () => ({ + localDb: { + select: mock(() => ({ + from: mock(() => ({ + where: mock(() => ({ + get: mock(() => null), + all: mock(() => []), + })), + get: mock(() => null), + all: mock(() => []), + })), + })), + insert: mock(() => ({ + values: mock(() => ({ + returning: mock(() => ({ + get: mock(() => ({ id: "test-id" })), + })), + onConflictDoUpdate: mock(() => ({ + run: mock(), + })), + run: mock(), + })), + })), + update: mock(() => ({ + set: mock(() => ({ + where: mock(() => ({ + run: mock(), + returning: mock(() => ({ + get: mock(() => ({ id: "test-id" })), + })), + })), + })), + })), + delete: mock(() => ({ + where: mock(() => ({ + run: mock(), + })), + })), + }, +})); diff --git a/bun.lock b/bun.lock index 3ba5310c9fe..d03a1053b22 100644 --- a/bun.lock +++ b/bun.lock @@ -28,6 +28,7 @@ "@trpc/server": "^11.7.1", "@trpc/tanstack-react-query": "^11.7.1", "date-fns": "^4.1.0", + "drizzle-orm": "0.45.1", "import-in-the-middle": "2.0.1", "next": "^16.0.10", "next-themes": "^0.4.6", @@ -114,7 +115,7 @@ }, "apps/desktop": { "name": "@superset/desktop", - "version": "0.0.31", + "version": "0.0.33", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", @@ -122,6 +123,7 @@ "@monaco-editor/react": "^4.7.0", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", + "@superset/local-db": "workspace:*", "@superset/shared": "workspace:*", "@superset/trpc": "workspace:*", "@superset/ui": "workspace:*", @@ -142,12 +144,14 @@ "@xterm/addon-web-links": "^0.11.0", "@xterm/addon-webgl": "^0.18.0", "@xterm/xterm": "^5.5.0", + "better-sqlite3": "12.5.0", "clsx": "^2.1.1", "culori": "^4.0.2", "date-fns": "^4.1.0", "default-shell": "^2.2.0", "dnd-core": "^16.0.1", "dotenv": "^17.2.3", + "drizzle-orm": "0.45.1", "electron-router-dom": "^2.1.0", "electron-updater": "6", "execa": "^9.6.0", @@ -191,6 +195,7 @@ "@biomejs/biome": "^2.3.8", "@superset/typescript": "workspace:*", "@tailwindcss/vite": "^4.0.9", + "@types/better-sqlite3": "^7.6.13", "@types/culori": "^4.0.1", "@types/http-proxy": "^1.17.17", "@types/lodash": "^4.17.20", @@ -356,6 +361,21 @@ "typescript": "^5.9.3", }, }, + "packages/local-db": { + "name": "@superset/local-db", + "version": "0.1.0", + "dependencies": { + "drizzle-orm": "0.45.1", + "uuid": "^13.0.0", + "zod": "^4.1.13", + }, + "devDependencies": { + "@superset/typescript": "workspace:*", + "@types/uuid": "^11.0.0", + "drizzle-kit": "0.31.8", + "typescript": "^5.9.3", + }, + }, "packages/queries": { "name": "@superset/queries", "version": "0.1.0", @@ -1202,6 +1222,8 @@ "@superset/docs": ["@superset/docs@workspace:apps/docs"], + "@superset/local-db": ["@superset/local-db@workspace:packages/local-db"], + "@superset/marketing": ["@superset/marketing@workspace:apps/marketing"], "@superset/queries": ["@superset/queries@workspace:packages/queries"], @@ -1302,6 +1324,8 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="], + "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], "@types/cacheable-request": ["@types/cacheable-request@6.0.3", "", { "dependencies": { "@types/http-cache-semantics": "*", "@types/keyv": "^3.1.4", "@types/node": "*", "@types/responselike": "^1.0.0" } }, "sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw=="], @@ -1466,6 +1490,8 @@ "@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="], + "@types/uuid": ["@types/uuid@11.0.0", "", { "dependencies": { "uuid": "*" } }, "sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA=="], + "@types/verror": ["@types/verror@1.10.11", "", {}, "sha512-RlDm9K7+o5stv0Co8i8ZRGxDbrTxhJtgjqjFyVh/tXQyl/rYtTKlnTvZ88oSTeYREWurwx20Js4kTuKCsFkUtg=="], "@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="], @@ -1634,10 +1660,14 @@ "better-react-mathjax": ["better-react-mathjax@2.3.0", "", { "dependencies": { "mathjax-full": "^3.2.2" }, "peerDependencies": { "react": ">=16.8" } }, "sha512-K0ceQC+jQmB+NLDogO5HCpqmYf18AU2FxDbLdduYgkHYWZApFggkHE4dIaXCV1NqeoscESYXXo1GSkY6fA295w=="], + "better-sqlite3": ["better-sqlite3@12.5.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-WwCZ/5Diz7rsF29o27o0Gcc1Du+l7Zsv7SYtVPG0X3G/uUI1LqdxrQI7c9Hs2FWpqXXERjW9hp6g3/tH7DlVKg=="], + "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], + "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], "body-parser": ["body-parser@2.2.1", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw=="], @@ -1894,6 +1924,8 @@ "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], + "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], + "default-shell": ["default-shell@2.2.0", "", {}, "sha512-sPpMZcVhRQ0nEMDtuMJ+RtCxt7iHPAMBU+I4tAlo5dU1sjRpNax0crj6nR3qKpvVnckaQ9U38enXcwW9nZJeCw=="], "defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="], @@ -2062,6 +2094,8 @@ "execa": ["execa@9.6.1", "", { "dependencies": { "@sindresorhus/merge-streams": "^4.0.0", "cross-spawn": "^7.0.6", "figures": "^6.1.0", "get-stream": "^9.0.0", "human-signals": "^8.0.1", "is-plain-obj": "^4.1.0", "is-stream": "^4.0.1", "npm-run-path": "^6.0.0", "pretty-ms": "^9.2.0", "signal-exit": "^4.1.0", "strip-final-newline": "^4.0.0", "yoctocolors": "^2.1.1" } }, "sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA=="], + "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], + "exponential-backoff": ["exponential-backoff@3.1.3", "", {}, "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA=="], "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], @@ -2096,6 +2130,8 @@ "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], + "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], + "filelist": ["filelist@1.0.4", "", { "dependencies": { "minimatch": "^5.0.1" } }, "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], @@ -2124,6 +2160,8 @@ "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], + "fs-extra": ["fs-extra@10.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ=="], "fs-minipass": ["fs-minipass@2.1.0", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg=="], @@ -2154,6 +2192,8 @@ "get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="], + "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], + "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], "glob": ["glob@13.0.0", "", { "dependencies": { "minimatch": "^10.1.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA=="], @@ -2274,6 +2314,8 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + "ink": ["ink@6.5.1", "", { "dependencies": { "@alcalzone/ansi-tokenize": "^0.2.1", "ansi-escapes": "^7.2.0", "ansi-styles": "^6.2.1", "auto-bind": "^5.0.1", "chalk": "^5.6.0", "cli-boxes": "^3.0.0", "cli-cursor": "^4.0.0", "cli-truncate": "^5.1.1", "code-excerpt": "^4.0.0", "es-toolkit": "^1.39.10", "indent-string": "^5.0.0", "is-in-ci": "^2.0.0", "patch-console": "^2.0.0", "react-reconciler": "^0.33.0", "signal-exit": "^3.0.7", "slice-ansi": "^7.1.0", "stack-utils": "^2.0.6", "string-width": "^8.1.0", "type-fest": "^4.27.0", "widest-line": "^5.0.0", "wrap-ansi": "^9.0.0", "ws": "^8.18.0", "yoga-layout": "~3.2.1" }, "peerDependencies": { "@types/react": ">=19.0.0", "react": ">=19.0.0", "react-devtools-core": "^6.1.2" }, "optionalPeers": ["@types/react", "react-devtools-core"] }, "sha512-wF3j/DmkM8q5E+OtfdQhCRw8/0ahkc8CUTgEddxZzpEWPslu7YPL3t64MWRoI9m6upVGpfAg4ms2BBvxCdKRLQ=="], "ink-select-input": ["ink-select-input@6.2.0", "", { "dependencies": { "figures": "^6.1.0", "to-rotated": "^1.0.0" }, "peerDependencies": { "ink": ">=5.0.0", "react": ">=18.0.0" } }, "sha512-304fZXxkpYxJ9si5lxRCaX01GNlmPBgOZumXXRnPYbHW/iI31cgQynqk2tRypGLOF1cMIwPUzL2LSm6q4I5rQQ=="], @@ -2662,6 +2704,8 @@ "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], + "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], + "mlly": ["mlly@1.8.0", "", { "dependencies": { "acorn": "^8.15.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.1" } }, "sha512-l8D9ODSRWLe2KHJSifWGwBqpTZXIXTeo8mlKjY+E2HAakaTeNpqAyBZ8GSqLzHgw4XmHmC8whvpjJNMbFZN7/g=="], "module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="], @@ -2678,6 +2722,8 @@ "nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="], + "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], @@ -2836,6 +2882,8 @@ "preact": ["preact@10.28.0", "", {}, "sha512-rytDAoiXr3+t6OIP3WGlDd0ouCUG1iCWzkcY3++Nreuoi17y6T5i/zRhe6uYfoVcxq6YU+sBtJouuRDsq8vvqA=="], + "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], + "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], @@ -2878,6 +2926,8 @@ "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], + "rdndmb-html5-to-touch": ["rdndmb-html5-to-touch@8.1.2", "", { "dependencies": { "dnd-multi-backend": "^8.1.2", "react-dnd-html5-backend": "^16.0.1", "react-dnd-touch-backend": "^16.0.1" } }, "sha512-efi3MaXYxWaLMd5xzF1bVvmX8erTMhYHSlaMjQe+tynf4IdtgRYfKLwYg+4Z5eq4k7idrjKHQOIMDE6D8LjnOA=="], "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], @@ -3136,6 +3186,10 @@ "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], + + "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], + "simple-git": ["simple-git@3.30.0", "", { "dependencies": { "@kwsites/file-exists": "^1.1.1", "@kwsites/promise-deferred": "^1.1.1", "debug": "^4.4.0" } }, "sha512-q6lxyDsCmEal/MEGhP1aVyQ3oxnagGlBDOVSIB4XUVLl1iZh0Pah6ebC9V4xBap/RfgP2WlI8EKs0WS0rMEJHg=="], "simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="], @@ -3214,6 +3268,8 @@ "strip-indent": ["strip-indent@4.1.1", "", {}, "sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA=="], + "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + "stripe-gradient": ["stripe-gradient@1.0.1", "", {}, "sha512-ttxSoPcJDXoYBPF7yG2TPC9ZZC1bc/ITxP8g0Yx5jo07dFT/wMcGn6CbjbHOtC0NBu8zZgJwJulCpK21WyRJEg=="], "style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="], @@ -3248,6 +3304,10 @@ "tar": ["tar@6.2.1", "", { "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" } }, "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A=="], + "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], + + "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], + "temp": ["temp@0.9.4", "", { "dependencies": { "mkdirp": "^0.5.1", "rimraf": "~2.6.2" } }, "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA=="], "temp-file": ["temp-file@3.4.0", "", { "dependencies": { "async-exit-hook": "^2.0.1", "fs-extra": "^10.0.0" } }, "sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg=="], @@ -3318,6 +3378,8 @@ "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], + "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], + "tunnel-rat": ["tunnel-rat@0.1.2", "", { "dependencies": { "zustand": "^4.3.2" } }, "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ=="], "turbo": ["turbo@2.6.3", "", { "optionalDependencies": { "turbo-darwin-64": "2.6.3", "turbo-darwin-arm64": "2.6.3", "turbo-linux-64": "2.6.3", "turbo-linux-arm64": "2.6.3", "turbo-windows-64": "2.6.3", "turbo-windows-arm64": "2.6.3" }, "bin": { "turbo": "bin/turbo" } }, "sha512-bf6YKUv11l5Xfcmg76PyWoy/e2vbkkxFNBGJSnfdSXQC33ZiUfutYh6IXidc5MhsnrFkWfdNNLyaRk+kHMLlwA=="], @@ -3408,7 +3470,7 @@ "utility-types": ["utility-types@3.11.0", "", {}, "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw=="], - "uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + "uuid": ["uuid@13.0.0", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w=="], "validate-npm-package-license": ["validate-npm-package-license@3.0.4", "", { "dependencies": { "spdx-correct": "^3.0.0", "spdx-expression-parse": "^3.0.0" } }, "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew=="], @@ -3606,6 +3668,8 @@ "@sentry/cli/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + "@sentry/webpack-plugin/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="], @@ -3824,6 +3888,8 @@ "react-devtools-core/ws": ["ws@7.5.10", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ=="], + "react-mosaic-component/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + "react-router/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], "read-pkg/normalize-package-data": ["normalize-package-data@3.0.3", "", { "dependencies": { "hosted-git-info": "^4.0.1", "is-core-module": "^2.5.0", "semver": "^7.3.4", "validate-npm-package-license": "^3.0.1" } }, "sha512-p2W1sgqij3zMMyRC067Dg16bfzVH+w7hyegmpIvZ4JNjqtGOVAIvLmjBx3yP7YTe9vKJgkoNOPjwQGogDoMXFA=="], @@ -3876,6 +3942,10 @@ "tar/minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="], + "tar-fs/chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], + + "tar-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + "temp/mkdirp": ["mkdirp@0.5.6", "", { "dependencies": { "minimist": "^1.2.6" }, "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw=="], "temp/rimraf": ["rimraf@2.6.3", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "./bin.js" } }, "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA=="], diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index fe58a2abe34..32c40a0adca 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -1,4 +1,3 @@ -export { and, eq, gt, gte, inArray, lt, lte, ne, not, or } from "drizzle-orm"; export { db, dbWs } from "./client"; export * as schema from "./schema"; export * from "./utils"; diff --git a/packages/local-db/drizzle.config.ts b/packages/local-db/drizzle.config.ts new file mode 100644 index 00000000000..7c57d842ab3 --- /dev/null +++ b/packages/local-db/drizzle.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + schema: "./src/schema/schema.ts", + out: "./drizzle", + dialect: "sqlite", +}); diff --git a/packages/local-db/drizzle/0000_initial_schema.sql b/packages/local-db/drizzle/0000_initial_schema.sql new file mode 100644 index 00000000000..3f5405be944 --- /dev/null +++ b/packages/local-db/drizzle/0000_initial_schema.sql @@ -0,0 +1,55 @@ +CREATE TABLE `projects` ( + `id` text PRIMARY KEY NOT NULL, + `main_repo_path` text NOT NULL, + `name` text NOT NULL, + `color` text NOT NULL, + `tab_order` integer, + `last_opened_at` integer NOT NULL, + `created_at` integer NOT NULL, + `config_toast_dismissed` integer, + `default_branch` text +); +--> statement-breakpoint +CREATE INDEX `projects_main_repo_path_idx` ON `projects` (`main_repo_path`);--> statement-breakpoint +CREATE INDEX `projects_last_opened_at_idx` ON `projects` (`last_opened_at`);--> statement-breakpoint +CREATE TABLE `settings` ( + `id` integer PRIMARY KEY DEFAULT 1 NOT NULL, + `last_active_workspace_id` text, + `last_used_app` text, + `terminal_presets` text, + `terminal_presets_initialized` integer, + `selected_ringtone_id` text +); +--> statement-breakpoint +CREATE TABLE `workspaces` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `worktree_id` text, + `type` text NOT NULL, + `branch` text NOT NULL, + `name` text NOT NULL, + `tab_order` integer NOT NULL, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL, + `last_opened_at` integer NOT NULL, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`worktree_id`) REFERENCES `worktrees`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `workspaces_project_id_idx` ON `workspaces` (`project_id`);--> statement-breakpoint +CREATE INDEX `workspaces_worktree_id_idx` ON `workspaces` (`worktree_id`);--> statement-breakpoint +CREATE INDEX `workspaces_last_opened_at_idx` ON `workspaces` (`last_opened_at`);--> statement-breakpoint +CREATE TABLE `worktrees` ( + `id` text PRIMARY KEY NOT NULL, + `project_id` text NOT NULL, + `path` text NOT NULL, + `branch` text NOT NULL, + `base_branch` text, + `created_at` integer NOT NULL, + `git_status` text, + `github_status` text, + FOREIGN KEY (`project_id`) REFERENCES `projects`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `worktrees_project_id_idx` ON `worktrees` (`project_id`);--> statement-breakpoint +CREATE INDEX `worktrees_branch_idx` ON `worktrees` (`branch`); \ No newline at end of file diff --git a/packages/local-db/drizzle/meta/0000_snapshot.json b/packages/local-db/drizzle/meta/0000_snapshot.json new file mode 100644 index 00000000000..99ebef37c6f --- /dev/null +++ b/packages/local-db/drizzle/meta/0000_snapshot.json @@ -0,0 +1,383 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "1b2cd610-2fb4-4910-91f5-0ca55ca6ac44", + "prevId": "00000000-0000-0000-0000-000000000000", + "tables": { + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "main_repo_path": { + "name": "main_repo_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config_toast_dismissed": { + "name": "config_toast_dismissed", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_branch": { + "name": "default_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "projects_main_repo_path_idx": { + "name": "projects_main_repo_path_idx", + "columns": [ + "main_repo_path" + ], + "isUnique": false + }, + "projects_last_opened_at_idx": { + "name": "projects_last_opened_at_idx", + "columns": [ + "last_opened_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "settings": { + "name": "settings", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_app": { + "name": "last_used_app", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_presets": { + "name": "terminal_presets", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terminal_presets_initialized": { + "name": "terminal_presets_initialized", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "selected_ringtone_id": { + "name": "selected_ringtone_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worktree_id": { + "name": "worktree_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tab_order": { + "name": "tab_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_opened_at": { + "name": "last_opened_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspaces_project_id_idx": { + "name": "workspaces_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "workspaces_worktree_id_idx": { + "name": "workspaces_worktree_id_idx", + "columns": [ + "worktree_id" + ], + "isUnique": false + }, + "workspaces_last_opened_at_idx": { + "name": "workspaces_last_opened_at_idx", + "columns": [ + "last_opened_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_project_id_projects_id_fk": { + "name": "workspaces_project_id_projects_id_fk", + "tableFrom": "workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_worktree_id_worktrees_id_fk": { + "name": "workspaces_worktree_id_worktrees_id_fk", + "tableFrom": "workspaces", + "tableTo": "worktrees", + "columnsFrom": [ + "worktree_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "worktrees": { + "name": "worktrees", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_branch": { + "name": "base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "git_status": { + "name": "git_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "github_status": { + "name": "github_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "worktrees_project_id_idx": { + "name": "worktrees_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "worktrees_branch_idx": { + "name": "worktrees_branch_idx", + "columns": [ + "branch" + ], + "isUnique": false + } + }, + "foreignKeys": { + "worktrees_project_id_projects_id_fk": { + "name": "worktrees_project_id_projects_id_fk", + "tableFrom": "worktrees", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/local-db/drizzle/meta/_journal.json b/packages/local-db/drizzle/meta/_journal.json new file mode 100644 index 00000000000..b818c4c9c63 --- /dev/null +++ b/packages/local-db/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1766444030452, + "tag": "0000_initial_schema", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/packages/local-db/package.json b/packages/local-db/package.json new file mode 100644 index 00000000000..e8172c03f45 --- /dev/null +++ b/packages/local-db/package.json @@ -0,0 +1,32 @@ +{ + "name": "@superset/local-db", + "version": "0.1.0", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + }, + "./schema": { + "types": "./src/schema/index.ts", + "default": "./src/schema/index.ts" + } + }, + "scripts": { + "clean": "git clean -xdf .cache .turbo dist node_modules", + "generate": "drizzle-kit generate", + "typecheck": "tsc --noEmit --emitDeclarationOnly false" + }, + "dependencies": { + "drizzle-orm": "0.45.1", + "uuid": "^13.0.0", + "zod": "^4.1.13" + }, + "devDependencies": { + "@superset/typescript": "workspace:*", + "@types/uuid": "^11.0.0", + "drizzle-kit": "0.31.8", + "typescript": "^5.9.3" + } +} diff --git a/packages/local-db/src/index.ts b/packages/local-db/src/index.ts new file mode 100644 index 00000000000..686fbd9e9f9 --- /dev/null +++ b/packages/local-db/src/index.ts @@ -0,0 +1 @@ +export * from "./schema"; diff --git a/packages/local-db/src/schema/index.ts b/packages/local-db/src/schema/index.ts new file mode 100644 index 00000000000..b5d33072d34 --- /dev/null +++ b/packages/local-db/src/schema/index.ts @@ -0,0 +1,3 @@ +export * from "./relations"; +export * from "./schema"; +export * from "./zod"; diff --git a/packages/local-db/src/schema/relations.ts b/packages/local-db/src/schema/relations.ts new file mode 100644 index 00000000000..d58548995f4 --- /dev/null +++ b/packages/local-db/src/schema/relations.ts @@ -0,0 +1,26 @@ +import { relations } from "drizzle-orm"; +import { projects, workspaces, worktrees } from "./schema"; + +export const projectsRelations = relations(projects, ({ many }) => ({ + worktrees: many(worktrees), + workspaces: many(workspaces), +})); + +export const worktreesRelations = relations(worktrees, ({ one, many }) => ({ + project: one(projects, { + fields: [worktrees.projectId], + references: [projects.id], + }), + workspaces: many(workspaces), +})); + +export const workspacesRelations = relations(workspaces, ({ one }) => ({ + project: one(projects, { + fields: [workspaces.projectId], + references: [projects.id], + }), + worktree: one(worktrees, { + fields: [workspaces.worktreeId], + references: [worktrees.id], + }), +})); diff --git a/packages/local-db/src/schema/schema.ts b/packages/local-db/src/schema/schema.ts new file mode 100644 index 00000000000..71c707f2e12 --- /dev/null +++ b/packages/local-db/src/schema/schema.ts @@ -0,0 +1,131 @@ +import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; +import { v4 as uuidv4 } from "uuid"; + +import type { + ExternalApp, + GitHubStatus, + GitStatus, + TerminalPreset, + WorkspaceType, +} from "./zod"; + +/** + * Projects table - represents a git repository that the user has opened + */ +export const projects = sqliteTable( + "projects", + { + id: text("id") + .primaryKey() + .$defaultFn(() => uuidv4()), + mainRepoPath: text("main_repo_path").notNull(), + name: text("name").notNull(), + color: text("color").notNull(), + tabOrder: integer("tab_order"), + lastOpenedAt: integer("last_opened_at") + .notNull() + .$defaultFn(() => Date.now()), + createdAt: integer("created_at") + .notNull() + .$defaultFn(() => Date.now()), + configToastDismissed: integer("config_toast_dismissed", { + mode: "boolean", + }), + defaultBranch: text("default_branch"), + }, + (table) => [ + index("projects_main_repo_path_idx").on(table.mainRepoPath), + index("projects_last_opened_at_idx").on(table.lastOpenedAt), + ], +); + +export type InsertProject = typeof projects.$inferInsert; +export type SelectProject = typeof projects.$inferSelect; + +/** + * Worktrees table - represents a git worktree within a project + */ +export const worktrees = sqliteTable( + "worktrees", + { + id: text("id") + .primaryKey() + .$defaultFn(() => uuidv4()), + projectId: text("project_id") + .notNull() + .references(() => projects.id, { onDelete: "cascade" }), + path: text("path").notNull(), + branch: text("branch").notNull(), + baseBranch: text("base_branch"), // The branch this worktree was created from + createdAt: integer("created_at") + .notNull() + .$defaultFn(() => Date.now()), + gitStatus: text("git_status", { mode: "json" }).$type(), + githubStatus: text("github_status", { mode: "json" }).$type(), + }, + (table) => [ + index("worktrees_project_id_idx").on(table.projectId), + index("worktrees_branch_idx").on(table.branch), + ], +); + +export type InsertWorktree = typeof worktrees.$inferInsert; +export type SelectWorktree = typeof worktrees.$inferSelect; + +/** + * Workspaces table - represents an active workspace (worktree or branch-based) + */ +export const workspaces = sqliteTable( + "workspaces", + { + id: text("id") + .primaryKey() + .$defaultFn(() => uuidv4()), + projectId: text("project_id") + .notNull() + .references(() => projects.id, { onDelete: "cascade" }), + worktreeId: text("worktree_id").references(() => worktrees.id, { + onDelete: "cascade", + }), // Only set for type="worktree" + type: text("type").notNull().$type(), + branch: text("branch").notNull(), // Branch name for both types + name: text("name").notNull(), + tabOrder: integer("tab_order").notNull(), + createdAt: integer("created_at") + .notNull() + .$defaultFn(() => Date.now()), + updatedAt: integer("updated_at") + .notNull() + .$defaultFn(() => Date.now()), + lastOpenedAt: integer("last_opened_at") + .notNull() + .$defaultFn(() => Date.now()), + }, + (table) => [ + index("workspaces_project_id_idx").on(table.projectId), + index("workspaces_worktree_id_idx").on(table.worktreeId), + index("workspaces_last_opened_at_idx").on(table.lastOpenedAt), + ], +); + +export type InsertWorkspace = typeof workspaces.$inferInsert; +export type SelectWorkspace = typeof workspaces.$inferSelect; + +/** + * Settings table - single row with typed columns + */ +export const settings = sqliteTable("settings", { + id: integer("id").primaryKey().default(1), + lastActiveWorkspaceId: text("last_active_workspace_id"), + lastUsedApp: text("last_used_app").$type(), + terminalPresets: text("terminal_presets", { mode: "json" }).$type< + TerminalPreset[] + >(), + terminalPresetsInitialized: integer("terminal_presets_initialized", { + mode: "boolean", + }), + selectedRingtoneId: text("selected_ringtone_id"), +}); + +export type InsertSettings = typeof settings.$inferInsert; +export type SelectSettings = typeof settings.$inferSelect; diff --git a/packages/local-db/src/schema/zod.ts b/packages/local-db/src/schema/zod.ts new file mode 100644 index 00000000000..f88f148da52 --- /dev/null +++ b/packages/local-db/src/schema/zod.ts @@ -0,0 +1,97 @@ +import { z } from "zod"; + +/** + * Git status for a worktree + */ +export const gitStatusSchema = z.object({ + branch: z.string(), + needsRebase: z.boolean(), + lastRefreshed: z.number(), +}); + +export type GitStatus = z.infer; + +/** + * GitHub check item + */ +export const checkItemSchema = z.object({ + name: z.string(), + status: z.enum(["success", "failure", "pending", "skipped", "cancelled"]), + url: z.string().optional(), +}); + +export type CheckItem = z.infer; + +/** + * GitHub PR status + */ +export const gitHubStatusSchema = z.object({ + pr: z + .object({ + number: z.number(), + title: z.string(), + url: z.string(), + state: z.enum(["open", "draft", "merged", "closed"]), + mergedAt: z.number().optional(), + additions: z.number(), + deletions: z.number(), + reviewDecision: z.enum(["approved", "changes_requested", "pending"]), + checksStatus: z.enum(["success", "failure", "pending", "none"]), + checks: z.array(checkItemSchema), + }) + .nullable(), + repoUrl: z.string(), + branchExistsOnRemote: z.boolean(), + lastRefreshed: z.number(), +}); + +export type GitHubStatus = z.infer; + +/** + * Terminal preset + */ +export const terminalPresetSchema = z.object({ + id: z.string(), + name: z.string(), + description: z.string().optional(), + cwd: z.string(), + commands: z.array(z.string()), +}); + +export type TerminalPreset = z.infer; + +/** + * Workspace type + */ +export const workspaceTypeSchema = z.enum(["worktree", "branch"]); + +export type WorkspaceType = z.infer; + +/** + * External apps that can be opened + */ +export const EXTERNAL_APPS = [ + "finder", + "vscode", + "cursor", + "sublime", + "xcode", + "iterm", + "warp", + "terminal", + // JetBrains IDEs + "intellij", + "webstorm", + "pycharm", + "phpstorm", + "rubymine", + "goland", + "clion", + "rider", + "datagrip", + "appcode", + "fleet", + "rustrover", +] as const; + +export type ExternalApp = (typeof EXTERNAL_APPS)[number]; diff --git a/packages/local-db/tsconfig.json b/packages/local-db/tsconfig.json new file mode 100644 index 00000000000..a26f9c4a15f --- /dev/null +++ b/packages/local-db/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "@superset/typescript/internal-package.json", + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src", "drizzle.config.ts"], + "exclude": ["node_modules"] +}