Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
"framer-motion": "^12.23.24",
"http-proxy": "^1.18.1",
"lowdb": "^7.0.1",
"nanoid": "^5.1.6",
"node-pty": "1.1.0-beta30",
"react": "^19.1.1",
"react-arborist": "^3.4.3",
Expand All @@ -63,6 +64,7 @@
"superjson": "^2.2.5",
"tailwind-merge": "^2.6.0",
"trpc-electron": "^0.1.2",
"zod": "^4.1.12",
"zustand": "^5.0.8"
},
"devDependencies": {
Expand Down
4 changes: 4 additions & 0 deletions apps/desktop/src/lib/trpc/routers/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import type { BrowserWindow } from "electron";
import { router } from "..";
import { createWindowRouter } from "./window";
import { createProjectsRouter } from "./projects";
import { createWorkspacesRouter } from "./workspaces";

/**
* Main application router
Expand All @@ -9,6 +11,8 @@ import { createWindowRouter } from "./window";
export const createAppRouter = (window: BrowserWindow) => {
return router({
window: createWindowRouter(window),
projects: createProjectsRouter(window),
workspaces: createWorkspacesRouter(),
});
};

Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/lib/trpc/routers/projects/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { createProjectsRouter } from "./projects";
export type { ProjectsRouter } from "./projects";
91 changes: 91 additions & 0 deletions apps/desktop/src/lib/trpc/routers/projects/projects.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { dialog } from "electron";
import type { BrowserWindow } from "electron";
import { basename } from "node:path";
import { z } from "zod";
import { publicProcedure, router } from "../..";
import { db } from "../../../../main/lib/db";
import type { RecentProject } from "../../../../main/lib/db/schemas";

/**
* Projects router
* Handles project selection, recents management, and workspace creation
*/
export const createProjectsRouter = (window: BrowserWindow) => {
return router({
/**
* Open a new project via folder picker
* Adds to recents and returns path for UI to handle
*/
openProject: publicProcedure.mutation(async () => {
const result = await dialog.showOpenDialog(window, {
properties: ["openDirectory"],
title: "Open Project",
});

if (result.canceled || result.filePaths.length === 0) {
return { success: false as const };
}

const path = result.filePaths[0];
const name = basename(path);

await db.update((data) => {
const existingIndex = data.recentProjects.findIndex(
(p) => p.path === path,
);
if (existingIndex !== -1) {
data.recentProjects[existingIndex].lastOpenedAt = Date.now();
} else {
data.recentProjects.push({
path,
name,
lastOpenedAt: Date.now(),
});
}
});

return {
success: true as const,
path,
name,
};
}),
openRecent: publicProcedure
.input(z.object({ path: z.string() }))
.mutation(async ({ input }) => {
const { path } = input;
const name = basename(path);

await db.update((data) => {
const recent = data.recentProjects.find((p) => p.path === path);
if (recent) {
recent.lastOpenedAt = Date.now();
}
});

return {
success: true as const,
path,
name,
};
}),
getRecents: publicProcedure.query((): RecentProject[] => {
return db.data.recentProjects
.slice()
.sort((a, b) => b.lastOpenedAt - a.lastOpenedAt);
}),
removeRecent: publicProcedure
.input(z.object({ path: z.string() }))
.mutation(async ({ input }) => {
await db.update((data) => {
data.recentProjects = data.recentProjects.filter(
(p) => p.path !== input.path,
);
});

return { success: true };
}),
});
};

export type ProjectsRouter = ReturnType<typeof createProjectsRouter>;
2 changes: 2 additions & 0 deletions apps/desktop/src/lib/trpc/routers/workspaces/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { createWorkspacesRouter } from "./workspaces";
export type { WorkspacesRouter } from "./workspaces";
216 changes: 216 additions & 0 deletions apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import { z } from "zod";
import { nanoid } from "nanoid";
import { publicProcedure, router } from "../..";
import { db } from "../../../../main/lib/db";

/**
* Workspaces router
* Handles workspace CRUD operations
*/
export const createWorkspacesRouter = () => {
return router({
/**
* Create a new workspace
*/
create: publicProcedure
.input(
z.object({
name: z.string(),
path: z.string().nullable().optional(),
}),
)
.mutation(async ({ input }) => {
// Set order to be at the end of the list
const maxOrder = db.data.workspaces.length > 0
? Math.max(...db.data.workspaces.map((w) => w.order))
: -1;

const workspace = {
id: nanoid(),
name: input.name,
path: input.path ?? null,
order: maxOrder + 1,
createdAt: Date.now(),
updatedAt: Date.now(),
lastOpenedAt: Date.now(),
};

await db.update((data) => {
data.workspaces.push(workspace);
data.settings.lastActiveWorkspaceId = workspace.id;
});

return workspace;
}),

/**
* Get a workspace by ID
*/
get: publicProcedure
.input(z.object({ id: z.string() }))
.query(({ input }) => {
const workspace = db.data.workspaces.find((w) => w.id === input.id);
return workspace || null;
}),

/**
* Get all workspaces sorted by order
*/
getAll: publicProcedure.query(() => {
return db.data.workspaces
.slice()
.sort((a, b) => a.order - b.order);
}),

/**
* Get the last active workspace
*/
getActive: publicProcedure.query(() => {
const { lastActiveWorkspaceId } = db.data.settings;

if (!lastActiveWorkspaceId) {
return null;
}

return db.data.workspaces.find((w) => w.id === lastActiveWorkspaceId) || null;
}),

/**
* Update a workspace
* Supports partial updates to workspace properties
*/
update: publicProcedure
.input(
z.object({
id: z.string(),
patch: z.object({
name: z.string().optional(),
path: z.string().nullable().optional(),
}),
}),
)
.mutation(async ({ input }) => {
await db.update((data) => {
const workspace = data.workspaces.find((w) => w.id === input.id);
if (!workspace) {
throw new Error(`Workspace ${input.id} not found`);
}

// Apply patches
if (input.patch.name !== undefined) {
workspace.name = input.patch.name;
}
if (input.patch.path !== undefined) {
workspace.path = input.patch.path;
}

// Update timestamps
workspace.updatedAt = Date.now();
workspace.lastOpenedAt = Date.now();
});

return { success: true };
}),

/**
* Delete a workspace
* Also removes from recents if no other workspace uses that path
*/
delete: publicProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input }) => {
const workspace = db.data.workspaces.find((w) => w.id === input.id);

if (!workspace) {
return { success: false, error: "Workspace not found" };
}

const workspacePath = workspace.path;

await db.update((data) => {
// Remove workspace
data.workspaces = data.workspaces.filter((w) => w.id !== input.id);

// Check if any other workspace uses this path
const otherWorkspaceWithSamePath = data.workspaces.some(
(w) => w.path === workspacePath,
);

// If no other workspace uses this path, remove from recents
if (!otherWorkspaceWithSamePath) {
data.recentProjects = data.recentProjects.filter(
(p) => p.path !== workspacePath,
);
}

// Update last active workspace if needed
if (data.settings.lastActiveWorkspaceId === input.id) {
// Set to the most recently opened workspace, if any
const sorted = data.workspaces
.slice()
.sort((a, b) => b.lastOpenedAt - a.lastOpenedAt);
data.settings.lastActiveWorkspaceId = sorted[0]?.id || undefined;
}
});

return { success: true };
}),

/**
* Set active workspace
*/
setActive: publicProcedure
.input(z.object({ id: z.string() }))
.mutation(async ({ input }) => {
await db.update((data) => {
const workspace = data.workspaces.find((w) => w.id === input.id);
if (!workspace) {
throw new Error(`Workspace ${input.id} not found`);
}

data.settings.lastActiveWorkspaceId = input.id;
workspace.lastOpenedAt = Date.now();
workspace.updatedAt = Date.now();
});

return { success: true };
}),

/**
* Reorder workspaces
*/
reorder: publicProcedure
.input(
z.object({
fromIndex: z.number(),
toIndex: z.number(),
}),
)
.mutation(async ({ input }) => {
await db.update((data) => {
const { fromIndex, toIndex } = input;

// Get all workspaces sorted by order
const workspaces = data.workspaces
.slice()
.sort((a, b) => a.order - b.order);

// Move workspace from fromIndex to toIndex
const [removed] = workspaces.splice(fromIndex, 1);
workspaces.splice(toIndex, 0, removed);

// Update order fields to reflect new positions
workspaces.forEach((workspace, index) => {
const ws = data.workspaces.find((w) => w.id === workspace.id);
if (ws) {
ws.order = index;
}
});
});

return { success: true };
}),
Comment on lines +182 to +212
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Guard against out-of-range indices in reorder to avoid runtime errors

reorder assumes fromIndex and toIndex are valid in the sorted workspaces array:

const [removed] = workspaces.splice(fromIndex, 1);
workspaces.splice(toIndex, 0, removed);

If fromIndex is out of bounds, removed becomes undefined, and workspace.id in the later forEach will throw a TypeError. Similarly, an invalid toIndex can insert undefined into the array.

Add explicit bounds checks before splicing:

await db.update((data) => {
  const { fromIndex, toIndex } = input;

  const workspaces = data.workspaces
    .slice()
    .sort((a, b) => a.order - b.order);

  const lastIndex = workspaces.length - 1;
  if (
    fromIndex < 0 ||
    fromIndex > lastIndex ||
    toIndex < 0 ||
    toIndex > lastIndex
  ) {
    // Either no-op or throw a typed error here
    return;
  }

  const [removed] = workspaces.splice(fromIndex, 1);
  workspaces.splice(toIndex, 0, removed);

  workspaces.forEach((workspace, index) => {
    const ws = data.workspaces.find((w) => w.id === workspace.id);
    if (ws) {
      ws.order = index;
    }
  });
});

This prevents malformed state and makes the mutation robust to bad indices.

🤖 Prompt for AI Agents
In apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts around lines
182–212, the reorder mutation assumes fromIndex/toIndex are valid and can splice
undefined into the workspaces array; add an explicit bounds check before any
splicing: compute lastIndex = workspaces.length - 1 and if fromIndex or toIndex
are outside 0..lastIndex return early (or throw a typed TRPC error), and ensure
you only proceed with splice and order reassignment when the removed item is
defined so you never access workspace.id on undefined.

});
};

export type WorkspacesRouter = ReturnType<typeof createWorkspacesRouter>;
4 changes: 4 additions & 0 deletions apps/desktop/src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { app } from "electron";
import { makeAppSetup } from "lib/electron-app/factories/app/setup";
import { registerStorageHandlers } from "./lib/storage-ipcs";
import { MainWindow } from "./windows/main";
import { initDb } from "./lib/db";

// Protocol scheme for deep linking
const PROTOCOL_SCHEME = "superset";
Expand Down Expand Up @@ -31,6 +32,9 @@ registerStorageHandlers();
(async () => {
await app.whenReady();

// Initialize database
await initDb();

await makeAppSetup(() => MainWindow());

// Stop all periodic rescans when app is quitting
Expand Down
26 changes: 26 additions & 0 deletions apps/desktop/src/main/lib/db/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { JSONFilePreset } from "lowdb/node";
import { join } from "node:path";
import { app } from "electron";
import type { Database } from "./schemas";
import { defaultDatabase } from "./schemas";

type DB = Awaited<ReturnType<typeof JSONFilePreset<Database>>>;

let _db: DB | null = null;

export async function initDb(): Promise<void> {
if (_db) return;

const dbPath = join(app.getPath("userData"), "db.json");
_db = await JSONFilePreset<Database>(dbPath, defaultDatabase);
console.log(`Database initialized at: ${dbPath}`);
}

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];
},
});
Comment on lines +1 to +26
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

Revisit DB type alias and be mindful of import-time db access

The lazy init + proxy pattern is nice, but two things to double-check:

  1. The DB type alias
type DB = Awaited<ReturnType<typeof JSONFilePreset<Database>>>;

Using typeof JSONFilePreset<Database> is likely not doing what you intend in TypeScript (you generally can’t apply type arguments on the value-side like this). Consider either:

  • Importing the DB type from lowdb and parameterizing it directly (e.g., Low<Database>), or
  • Falling back to type DB = Awaited<ReturnType<typeof JSONFilePreset>>; if you’re OK with looser typing.

Please run the typecheck to confirm this compiles as expected.

  1. Proxy usage

The proxy correctly throws when _db is null, but that also means any top-level access of db in other modules (outside of functions/handlers called after initDb) will hard-fail at import time. Make sure all consumers only touch db after initDb has been awaited (as wired in apps/desktop/src/main/index.ts).


I'll verify the concerns raised in the review comment by checking TypeScript compilation, inspecting how db is used throughout the codebase, and verifying the initialization sequence.


🏁 Script executed:

#!/bin/bash
# Check if TypeScript compilation succeeds
cd /repo
if [ -f "tsconfig.json" ]; then
  echo "=== Running TypeScript type check ==="
  npx tsc --noEmit 2>&1 | head -50
else
  echo "tsconfig.json not found"
fi

Length of output: 79


🏁 Script executed:

#!/bin/bash
# Find all usages of the db export from index.ts
cd /repo
echo "=== Searching for imports/usages of db from db/index.ts ==="
rg -n "from ['\"].*db/index['\"]|from ['\"].*db['\"]" --type=ts --type=tsx -A 2 -B 2

Length of output: 143


🏁 Script executed:

#!/bin/bash
# Check the main entry point for initialization order
cd /repo
echo "=== Checking apps/desktop/src/main/index.ts for initDb call ==="
if [ -f "apps/desktop/src/main/index.ts" ]; then
  cat "apps/desktop/src/main/index.ts"
else
  echo "File not found, searching for main entry point..."
  find apps/desktop/src/main -name "index.ts" -o -name "main.ts" | head -5
fi

Length of output: 1268


🏁 Script executed:

#!/bin/bash
# Search for direct db access patterns outside of function scopes
cd /repo
echo "=== Searching for top-level db accesses ==="
rg -n "\bdb\." --type=ts --type=tsx | grep -v "function\|const\|let\|async\|=>|//|initDb" | head -20

Length of output: 127


I see the issue with the previous scripts. Let me regenerate them to work with the correct environment and settings.


🏁 Script executed:

#!/bin/bash
# Check if TypeScript exists and run type check
echo "=== Checking TypeScript and running type check ==="
if command -v tsc &> /dev/null; then
  tsc --noEmit 2>&1 | grep -i "error\|db" | head -30
else
  echo "tsc not found, checking for lowdb types..."
  find . -name "lowdb" -type d | head -5
  find . -name "package.json" | head -1 | xargs grep -l "lowdb" 2>/dev/null
fi

Length of output: 93


🏁 Script executed:

#!/bin/bash
# Search for all db imports and usages
echo "=== Searching for db imports ==="
rg -n "from.*db/index|import.*db" --type=js -A 3 -B 1 | head -80

Length of output: 32


🏁 Script executed:

#!/bin/bash
# Check lowdb version and exported types in package.json
echo "=== Checking lowdb package ==="
find . -name "package.json" | xargs grep -A 2 -B 2 "lowdb" 2>/dev/null | head -20

Length of output: 846


🏁 Script executed:

#!/bin/bash
# Look for top-level db property access (not inside functions)
echo "=== Checking for direct db accesses ==="
rg -n "db\.\w+" --type=js -B 5 -A 2 | grep -v "function\|async\|=>\|//" | head -50

Length of output: 39


🏁 Script executed:

#!/bin/bash
# Verify the db/schemas file exists and check Database type
echo "=== Checking db/schemas file ==="
if [ -f "apps/desktop/src/main/lib/db/schemas.ts" ]; then
  echo "=== Content of schemas.ts ==="
  cat "apps/desktop/src/main/lib/db/schemas.ts"
else
  find . -name "schemas.ts" -path "*db*" | xargs cat 2>/dev/null
fi

Length of output: 1029


Let me search more thoroughly for db usages and verify the lowdb type behavior.

[/run_scripts]

[/web_search]


🏁 Script executed:

#!/bin/bash
# Search with different patterns for db usage
echo "=== Searching all .ts files for db references ==="
find . -name "*.ts" -type f | xargs grep -l "db\." 2>/dev/null | head -20

Length of output: 981


🏁 Script executed:

#!/bin/bash
# Check the storage-ipcs handler mentioned in main entry
echo "=== Checking registerStorageHandlers ==="
find . -path "*storage-ipcs*" -name "*.ts" | head -5 | xargs cat 2>/dev/null | head -100

Length of output: 646


🏁 Script executed:

#!/bin/bash
# Look for any imports of db from db/index
echo "=== Searching for db imports from db/index ==="
grep -r "from.*db/index\|from.*db\"" --include="*.ts" . 2>/dev/null | head -30

Length of output: 324


🏁 Script executed:

#!/bin/bash
# Check if db is ever accessed at module level (not in functions)
echo "=== Checking db/index.ts for the DB type alias ==="
cat "apps/desktop/src/main/lib/db/index.ts" | head -30

Length of output: 777


🌐 Web query:

lowdb v7 JSONFilePreset type signature TypeScript

💡 Result:

Signature (TypeScript):

async function JSONFilePreset(filename: fs.PathLike | string, defaultData: T): Promise<Low>

Usage example:
const db = await JSONFilePreset('db.json', defaultData);

Sources: lowdb README and v7 release notes. [1][2]

References
[1] lowdb README (usage / presets).
[2] lowdb v7 release notes (presets renamed to JSONFilePreset).


[/run_scripts]


🏁 Script executed:

#!/bin/bash
# Check how db is used in projects router
echo "=== Checking projects/projects.ts db usage ==="
cat "./apps/desktop/src/lib/trpc/routers/projects/projects.ts" | grep -A 10 -B 5 "db\."

Length of output: 1307


🏁 Script executed:

#!/bin/bash
# Check how db is used in workspaces router
echo "=== Checking workspaces/workspaces.ts db usage ==="
cat "./apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts" | grep -A 10 -B 5 "db\."

Length of output: 3760


🏁 Script executed:

#!/bin/bash
# Verify the actual type returned by JSONFilePreset
echo "=== Checking lowdb Low type structure ==="
find . -path "*node_modules/lowdb*" -name "*.d.ts" | head -5 | xargs grep -A 3 "class Low\|interface Low\|export.*Low" 2>/dev/null | head -30

Length of output: 41


Fix the DB type alias to preserve TypeScript type safety

The lazy init + proxy pattern is sound, and all db consumers correctly access it only within handler callbacks (never at module level). However, the type alias does have an issue:

type DB = Awaited<ReturnType<typeof JSONFilePreset<Database>>>;

typeof JSONFilePreset<Database> is invalid in TypeScript—type parameters cannot be applied on the value side. This causes the alias to resolve to Low<unknown>, losing the Database type information. TypeScript will not validate that consumers are accessing the correct properties.

To fix, import Low from lowdb and parameterize it directly:

import { Low } from "lowdb";
type DB = Low<Database>;

Alternatively, if you prefer avoiding the extra import, use typeof JSONFilePreset without the type parameter (though this will still result in Low<unknown>).

The import-time access concern is addressed by proper initialization sequencing in apps/desktop/src/main/index.ts—all db accesses occur only within handler callbacks after initDb() is awaited.

🤖 Prompt for AI Agents
In apps/desktop/src/main/lib/db/index.ts lines 1-26, the DB type alias uses
Awaited<ReturnType<typeof JSONFilePreset<Database>>> which misapplies a type
parameter to a value and collapses to Low<unknown>; replace that with an
explicit Low<Database> type by importing Low from "lowdb" and change the DB
alias to Low<Database>, and update imports accordingly so TypeScript preserves
the Database shape.

Loading
Loading