feat: use workspace ids instead of paths for workspace operations

This commit is contained in:
Haileyesus
2026-04-07 17:03:35 +03:00
parent 6589867d78
commit 8e6fc15a1d
12 changed files with 204 additions and 55 deletions

View File

@@ -16,17 +16,17 @@ const getTrimmedString = (value: unknown): string => {
return value.trim();
};
const parseWorkspacePathFromBody = (req: Request): string => {
const parseWorkspaceIdFromBody = (req: Request): string => {
const body = req.body as Record<string, unknown> | undefined;
const workspacePath = getTrimmedString(body?.workspacePath);
if (!workspacePath) {
throw new AppError('workspacePath is required.', {
code: 'WORKSPACE_PATH_REQUIRED',
const workspaceId = getTrimmedString(body?.workspaceId);
if (!workspaceId) {
throw new AppError('workspaceId is required.', {
code: 'WORKSPACE_ID_REQUIRED',
statusCode: 400,
});
}
return workspacePath;
return workspaceId;
};
const parseWorkspaceCustomNameFromBody = (req: Request): string | null => {
@@ -46,27 +46,27 @@ router.get(
router.patch(
'/star',
asyncHandler(async (req: Request, res: Response) => {
const workspacePath = parseWorkspacePathFromBody(req);
const isStarred = workspaceService.toggleWorkspaceStar(workspacePath);
res.json(createApiSuccessResponse({ workspacePath, isStarred }));
const workspaceId = parseWorkspaceIdFromBody(req);
const isStarred = workspaceService.toggleWorkspaceStar(workspaceId);
res.json(createApiSuccessResponse({ workspaceId, isStarred }));
}),
);
router.patch(
'/name',
asyncHandler(async (req: Request, res: Response) => {
const workspacePath = parseWorkspacePathFromBody(req);
const workspaceId = parseWorkspaceIdFromBody(req);
const workspaceCustomName = parseWorkspaceCustomNameFromBody(req);
workspaceService.updateWorkspaceCustomName(workspacePath, workspaceCustomName);
res.json(createApiSuccessResponse({ workspacePath, workspaceCustomName }));
workspaceService.updateWorkspaceCustomName(workspaceId, workspaceCustomName);
res.json(createApiSuccessResponse({ workspaceId, workspaceCustomName }));
}),
);
router.delete(
'/',
asyncHandler(async (req: Request, res: Response) => {
const workspacePath = parseWorkspacePathFromBody(req);
const result = await workspaceService.deleteWorkspace(workspacePath);
const workspaceId = parseWorkspaceIdFromBody(req);
const result = await workspaceService.deleteWorkspace(workspaceId);
res.json(createApiSuccessResponse(result));
}),
);

View File

@@ -19,6 +19,7 @@ export type WorkspaceSessionRecord = {
};
export type WorkspaceRecord = {
workspaceId: string;
workspaceOriginalPath: string;
workspaceCustomName: string | null;
workspaceDisplayName: string;
@@ -107,6 +108,7 @@ const buildWorkspaceSessionCollection = (): WorkspaceRecord[] => {
const lastActivity = sessions[0]?.lastActivity || null;
return {
workspaceId: workspaceRow.workspace_id,
workspaceOriginalPath: workspaceRow.workspace_path,
workspaceCustomName: workspaceRow.custom_workspace_name,
workspaceDisplayName:
@@ -130,8 +132,8 @@ export const workspaceService = {
return buildWorkspaceSessionCollection();
},
toggleWorkspaceStar(workspacePath: string): boolean {
const workspaceRow = workspaceOriginalPathsDb.getWorkspacePath(workspacePath);
toggleWorkspaceStar(workspaceId: string): boolean {
const workspaceRow = workspaceOriginalPathsDb.getWorkspaceById(workspaceId);
if (!workspaceRow) {
throw new AppError('Workspace not found.', {
code: 'WORKSPACE_NOT_FOUND',
@@ -140,22 +142,40 @@ export const workspaceService = {
}
const nextIsStarred = workspaceRow.isStarred !== 1;
workspaceOriginalPathsDb.updateWorkspaceIsStarred(workspacePath, nextIsStarred);
workspaceOriginalPathsDb.updateWorkspaceIsStarredById(workspaceId, nextIsStarred);
return nextIsStarred;
},
updateWorkspaceCustomName(workspacePath: string, workspaceCustomName: string | null): void {
workspaceOriginalPathsDb.updateCustomWorkspaceName(workspacePath, workspaceCustomName);
updateWorkspaceCustomName(workspaceId: string, workspaceCustomName: string | null): void {
const workspaceRow = workspaceOriginalPathsDb.getWorkspaceById(workspaceId);
if (!workspaceRow) {
throw new AppError('Workspace not found.', {
code: 'WORKSPACE_NOT_FOUND',
statusCode: 404,
});
}
workspaceOriginalPathsDb.updateCustomWorkspaceNameById(workspaceId, workspaceCustomName);
},
async deleteWorkspace(workspacePath: string): Promise<{
async deleteWorkspace(workspaceId: string): Promise<{
workspaceId: string;
workspacePath: string;
deletedWorkspace: boolean;
deletedSessionCount: number;
jsonlDeletedCount: number;
failedSessionFileDeletes: string[];
}> {
const workspaceRow = workspaceOriginalPathsDb.getWorkspaceById(workspaceId);
if (!workspaceRow) {
throw new AppError('Workspace not found.', {
code: 'WORKSPACE_NOT_FOUND',
statusCode: 404,
});
}
const workspacePath = workspaceRow.workspace_path;
const sessionRows = sessionsDb.getSessionsByWorkspacePath(workspacePath);
const failedSessionFileDeletes: string[] = [];
let jsonlDeletedCount = 0;
@@ -175,9 +195,10 @@ export const workspaceService = {
}
}
workspaceOriginalPathsDb.deleteWorkspacePath(workspacePath);
workspaceOriginalPathsDb.deleteWorkspaceById(workspaceId);
return {
workspaceId,
workspacePath,
deletedWorkspace: true,
deletedSessionCount: sessionRows.length,

View File

@@ -10,6 +10,14 @@ import {
} from "@/shared/database/schema.js";
import { logger } from "@/shared/utils/logger.js";
const SQLITE_UUID_SQL = `
lower(hex(randomblob(4))) || '-' ||
lower(hex(randomblob(2))) || '-' ||
lower(hex(randomblob(2))) || '-' ||
lower(hex(randomblob(2))) || '-' ||
lower(hex(randomblob(6)))
`;
const addColumnToTableIfNotExists = (
db: Database,
tableName: string,
@@ -61,7 +69,9 @@ export const runMigrations = (db: Database) => {
db.exec("UPDATE sessions SET updated_at = COALESCE(updated_at, CURRENT_TIMESTAMP)");
db.exec(WORK_SPACE_PATH_SQL);
const workspaceOriginalPathsTableInfo = db.prepare("PRAGMA table_info(workspace_original_paths)").all() as { name: string }[];
const workspaceOriginalPathsTableInfo = db
.prepare("PRAGMA table_info(workspace_original_paths)")
.all() as { name: string; pk: number }[];
const workspaceOriginalPathsColumnNames = workspaceOriginalPathsTableInfo.map((col) => col.name);
addColumnToTableIfNotExists(
db,
@@ -70,6 +80,13 @@ export const runMigrations = (db: Database) => {
"custom_workspace_name",
"TEXT DEFAULT NULL",
);
addColumnToTableIfNotExists(
db,
"workspace_original_paths",
workspaceOriginalPathsColumnNames,
"workspace_id",
"TEXT",
);
addColumnToTableIfNotExists(
db,
"workspace_original_paths",
@@ -77,9 +94,61 @@ export const runMigrations = (db: Database) => {
"isStarred",
"BOOLEAN DEFAULT 0",
);
db.exec(`
UPDATE workspace_original_paths
SET workspace_id = ${SQLITE_UUID_SQL}
WHERE workspace_id IS NULL OR trim(workspace_id) = ''
`);
const workspaceOriginalPathsPrimaryKeyColumn =
workspaceOriginalPathsTableInfo.find((column) => column.pk === 1)?.name ?? null;
if (workspaceOriginalPathsPrimaryKeyColumn !== "workspace_id") {
logger.info(
"Running migration: Rebuilding workspace_original_paths to set workspace_id as primary key",
);
db.exec("PRAGMA foreign_keys = OFF");
try {
db.exec("BEGIN TRANSACTION");
db.exec("DROP TABLE IF EXISTS workspace_original_paths__new");
db.exec(`
CREATE TABLE workspace_original_paths__new (
workspace_id TEXT PRIMARY KEY NOT NULL,
workspace_path TEXT NOT NULL UNIQUE,
custom_workspace_name TEXT DEFAULT NULL,
isStarred BOOLEAN DEFAULT 0
)
`);
db.exec(`
INSERT INTO workspace_original_paths__new (
workspace_id,
workspace_path,
custom_workspace_name,
isStarred
)
SELECT
CASE
WHEN workspace_id IS NULL OR trim(workspace_id) = ''
THEN ${SQLITE_UUID_SQL}
ELSE workspace_id
END,
workspace_path,
custom_workspace_name,
COALESCE(isStarred, 0)
FROM workspace_original_paths
`);
db.exec("DROP TABLE workspace_original_paths");
db.exec("ALTER TABLE workspace_original_paths__new RENAME TO workspace_original_paths");
db.exec("COMMIT");
} catch (migrationError) {
db.exec("ROLLBACK");
throw migrationError;
} finally {
db.exec("PRAGMA foreign_keys = ON");
}
}
db.exec(
"CREATE INDEX IF NOT EXISTS idx_workspace_original_paths_is_starred ON workspace_original_paths(isStarred)"
);
db.exec("DROP INDEX IF EXISTS idx_workspace_original_paths_workspace_id");
db.exec(LAST_SCANNED_AT_SQL);

View File

@@ -1,3 +1,5 @@
import { randomUUID } from 'node:crypto';
import { getConnection } from '@/shared/database/connection.js';
import type { WorkspaceOriginalPathRow } from '@/shared/database/types.js';
@@ -5,21 +7,21 @@ export const workspaceOriginalPathsDb = {
createWorkspacePath(workspacePath: string, customWorkspaceName: string | null = null): void {
const db = getConnection();
db.prepare(`
INSERT INTO workspace_original_paths (workspace_path, custom_workspace_name)
VALUES (?, ?)
INSERT INTO workspace_original_paths (workspace_id, workspace_path, custom_workspace_name)
VALUES (?, ?, ?)
ON CONFLICT(workspace_path) DO UPDATE SET
custom_workspace_name = CASE
WHEN workspace_original_paths.custom_workspace_name IS NULL OR workspace_original_paths.custom_workspace_name = ''
THEN excluded.custom_workspace_name
ELSE workspace_original_paths.custom_workspace_name
END
`).run(workspacePath, customWorkspaceName);
`).run(randomUUID(), workspacePath, customWorkspaceName);
},
getWorkspacePath(workspacePath: string): WorkspaceOriginalPathRow | null {
const db = getConnection();
const row = db.prepare(`
SELECT workspace_path, custom_workspace_name, isStarred
SELECT workspace_id, workspace_path, custom_workspace_name, isStarred
FROM workspace_original_paths
WHERE workspace_path = ?
`).get(workspacePath) as WorkspaceOriginalPathRow | undefined;
@@ -27,10 +29,21 @@ export const workspaceOriginalPathsDb = {
return row ?? null;
},
getWorkspaceById(workspaceId: string): WorkspaceOriginalPathRow | null {
const db = getConnection();
const row = db.prepare(`
SELECT workspace_id, workspace_path, custom_workspace_name, isStarred
FROM workspace_original_paths
WHERE workspace_id = ?
`).get(workspaceId) as WorkspaceOriginalPathRow | undefined;
return row ?? null;
},
getWorkspacePaths(): WorkspaceOriginalPathRow[] {
const db = getConnection();
return db.prepare(`
SELECT workspace_path, custom_workspace_name, isStarred
SELECT workspace_id, workspace_path, custom_workspace_name, isStarred
FROM workspace_original_paths
`).all() as WorkspaceOriginalPathRow[];
},
@@ -49,10 +62,19 @@ export const workspaceOriginalPathsDb = {
updateCustomWorkspaceName(workspacePath: string, customWorkspaceName: string | null): void {
const db = getConnection();
db.prepare(`
INSERT INTO workspace_original_paths (workspace_path, custom_workspace_name)
VALUES (?, ?)
INSERT INTO workspace_original_paths (workspace_id, workspace_path, custom_workspace_name)
VALUES (?, ?, ?)
ON CONFLICT(workspace_path) DO UPDATE SET custom_workspace_name = excluded.custom_workspace_name
`).run(workspacePath, customWorkspaceName);
`).run(randomUUID(), workspacePath, customWorkspaceName);
},
updateCustomWorkspaceNameById(workspaceId: string, customWorkspaceName: string | null): void {
const db = getConnection();
db.prepare(`
UPDATE workspace_original_paths
SET custom_workspace_name = ?
WHERE workspace_id = ?
`).run(customWorkspaceName, workspaceId);
},
updateWorkspaceIsStarred(workspacePath: string, isStarred: boolean): void {
@@ -64,6 +86,15 @@ export const workspaceOriginalPathsDb = {
`).run(isStarred ? 1 : 0, workspacePath);
},
updateWorkspaceIsStarredById(workspaceId: string, isStarred: boolean): void {
const db = getConnection();
db.prepare(`
UPDATE workspace_original_paths
SET isStarred = ?
WHERE workspace_id = ?
`).run(isStarred ? 1 : 0, workspaceId);
},
deleteWorkspacePath(workspacePath: string): void {
const db = getConnection();
db.prepare(`
@@ -71,4 +102,12 @@ export const workspaceOriginalPathsDb = {
WHERE workspace_path = ?
`).run(workspacePath);
},
deleteWorkspaceById(workspaceId: string): void {
const db = getConnection();
db.prepare(`
DELETE FROM workspace_original_paths
WHERE workspace_id = ?
`).run(workspaceId);
},
};

View File

@@ -86,7 +86,8 @@ CREATE TABLE IF NOT EXISTS sessions (
export const WORK_SPACE_PATH_SQL = `
CREATE TABLE IF NOT EXISTS workspace_original_paths (
workspace_path TEXT PRIMARY KEY NOT NULL,
workspace_id TEXT PRIMARY KEY NOT NULL,
workspace_path TEXT NOT NULL UNIQUE,
custom_workspace_name TEXT DEFAULT NULL,
isStarred BOOLEAN DEFAULT 0
);

View File

@@ -129,6 +129,7 @@ export type SessionWithSummary = {
};
export type WorkspaceOriginalPathRow = {
workspace_id: string;
workspace_path: string;
custom_workspace_name: string | null;
isStarred: number; // SQLite boolean: 0 | 1

View File

@@ -7,6 +7,7 @@ import { RootLayout } from '@/components/refactored/shared/RootLayout';
// Mock page components
const Home = () => <div className="p-8"><h1>Home Page</h1><p>Select a session or create a new project.</p></div>;
const WorkspaceContent = () => <div className="p-8"><h1>Workspace View</h1><p>Select a session or start a new one.</p></div>;
const SessionContent = () => <div className="p-8"><h1>Session View</h1><p>Chat interface goes here.</p></div>;
const router = createBrowserRouter([
@@ -26,6 +27,10 @@ const router = createBrowserRouter([
path: "/sessions/:sessionId",
element: <SessionContent />,
},
{
path: "/workspace/:workspaceId",
element: <WorkspaceContent />,
},
],
},
]);

View File

@@ -57,16 +57,16 @@ export const getWorkspaceSessions = async (): Promise<WorkspaceRecord[]> => {
};
export const updateWorkspaceStar = async (
workspacePath: string,
): Promise<{ workspacePath: string; isStarred: boolean }> => {
workspaceId: string,
): Promise<{ workspaceId: string; isStarred: boolean }> => {
const response = await authenticatedFetch(SIDEBAR_ENDPOINTS.updateWorkspaceStar, {
method: 'PATCH',
body: JSON.stringify({ workspacePath }),
body: JSON.stringify({ workspaceId }),
});
const payload = await parseJsonSafely<{
success?: boolean;
data?: {
workspacePath?: string;
workspaceId?: string;
isStarred?: boolean;
};
error?: { message?: string };
@@ -80,18 +80,18 @@ export const updateWorkspaceStar = async (
}
return {
workspacePath: payload?.data?.workspacePath || workspacePath,
workspaceId: payload?.data?.workspaceId || workspaceId,
isStarred: Boolean(payload?.data?.isStarred),
};
};
export const updateWorkspaceCustomName = async (
workspacePath: string,
workspaceId: string,
workspaceCustomName: string | null,
): Promise<void> => {
const response = await authenticatedFetch(SIDEBAR_ENDPOINTS.updateWorkspaceCustomName, {
method: 'PATCH',
body: JSON.stringify({ workspacePath, workspaceCustomName }),
body: JSON.stringify({ workspaceId, workspaceCustomName }),
});
const payload = await parseJsonSafely<{ error?: { message?: string } }>(response);
@@ -103,10 +103,10 @@ export const updateWorkspaceCustomName = async (
}
};
export const deleteWorkspaceByPath = async (workspacePath: string): Promise<void> => {
export const deleteWorkspaceById = async (workspaceId: string): Promise<void> => {
const response = await authenticatedFetch(SIDEBAR_ENDPOINTS.deleteWorkspace, {
method: 'DELETE',
body: JSON.stringify({ workspacePath }),
body: JSON.stringify({ workspaceId }),
});
const payload = await parseJsonSafely<{ error?: { message?: string } }>(response);

View File

@@ -3,7 +3,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
import {
deleteSessionById,
deleteWorkspaceByPath,
deleteWorkspaceById,
getWorkspaceSessions,
updateSessionCustomName,
updateWorkspaceCustomName,
@@ -95,7 +95,7 @@ export const useWorkspaces = () => {
[filteredWorkspaces],
);
const toggleWorkspace = useCallback((workspacePath: string) => {
const toggleWorkspace = useCallback((workspaceId: string, workspacePath: string) => {
setExpandedWorkspaces((previousSet) => {
const nextSet = new Set(previousSet);
@@ -107,7 +107,8 @@ export const useWorkspaces = () => {
return nextSet;
});
}, []);
navigate(`/workspace/${encodeURIComponent(workspaceId)}`);
}, [navigate]);
const openSession = useCallback(
(workspacePath: string, sessionId: string) => {
@@ -125,9 +126,9 @@ export const useWorkspaces = () => {
navigate('/');
}, [navigate]);
const toggleWorkspaceStar = useCallback(async (workspacePath: string) => {
const toggleWorkspaceStar = useCallback(async (workspaceId: string) => {
try {
await updateWorkspaceStar(workspacePath);
await updateWorkspaceStar(workspaceId);
await refreshWorkspaces();
} catch (error) {
console.error('Failed to update workspace star:', error);
@@ -149,10 +150,17 @@ export const useWorkspaces = () => {
return;
}
const editingWorkspace = workspaces.find(
(workspace) => workspace.workspaceOriginalPath === editingWorkspacePath,
);
if (!editingWorkspace) {
return;
}
setIsSavingWorkspaceName(true);
try {
const trimmedName = editingWorkspaceName.trim();
await updateWorkspaceCustomName(editingWorkspacePath, trimmedName || null);
await updateWorkspaceCustomName(editingWorkspace.workspaceId, trimmedName || null);
await refreshWorkspaces();
cancelWorkspaceRename();
} catch (error) {
@@ -165,10 +173,12 @@ export const useWorkspaces = () => {
editingWorkspaceName,
editingWorkspacePath,
refreshWorkspaces,
workspaces,
]);
const requestWorkspaceDelete = useCallback((workspace: WorkspaceRecord) => {
setWorkspaceDeleteTarget({
workspaceId: workspace.workspaceId,
workspacePath: workspace.workspaceOriginalPath,
workspaceName: getWorkspaceDisplayName(workspace),
sessionCount: workspace.sessions.length,
@@ -184,10 +194,11 @@ export const useWorkspaces = () => {
return;
}
const deletingWorkspaceId = workspaceDeleteTarget.workspaceId;
const deletingWorkspacePath = workspaceDeleteTarget.workspacePath;
setWorkspaceDeleteTarget(null);
try {
await deleteWorkspaceByPath(deletingWorkspacePath);
await deleteWorkspaceById(deletingWorkspaceId);
// If the current session belonged to the deleted workspace, reset to root.
const hadSelectedSession = workspaces.some(

View File

@@ -15,6 +15,7 @@ export type WorkspaceSession = {
};
export type WorkspaceRecord = {
workspaceId: string;
workspaceOriginalPath: string;
workspaceCustomName: string | null;
workspaceDisplayName: string;
@@ -24,6 +25,7 @@ export type WorkspaceRecord = {
};
export type WorkspaceDeleteTarget = {
workspaceId: string;
workspacePath: string;
workspaceName: string;
sessionCount: number;

View File

@@ -29,8 +29,8 @@ type SidebarWorkspaceItemProps = {
isSavingSessionName: boolean;
onEditingWorkspaceNameChange: (name: string) => void;
onEditingSessionNameChange: (name: string) => void;
onToggleWorkspace: (workspacePath: string) => void;
onToggleWorkspaceStar: (workspacePath: string) => void;
onToggleWorkspace: (workspaceId: string, workspacePath: string) => void;
onToggleWorkspaceStar: (workspaceId: string) => void;
onStartWorkspaceRename: (workspace: WorkspaceRecord) => void;
onCancelWorkspaceRename: () => void;
onSaveWorkspaceRename: () => void;
@@ -95,7 +95,7 @@ export function SidebarWorkspaceItem({
!hasSelectedSession &&
'border-yellow-200/30 bg-yellow-50/50 dark:border-yellow-800/30 dark:bg-yellow-900/5',
)}
onClick={() => onToggleWorkspace(workspace.workspaceOriginalPath)}
onClick={() => onToggleWorkspace(workspace.workspaceId, workspace.workspaceOriginalPath)}
>
<div className="flex items-center justify-between">
<div className="flex min-w-0 flex-1 items-center gap-3">
@@ -179,7 +179,7 @@ export function SidebarWorkspaceItem({
)}
onClick={(event) => {
event.stopPropagation();
onToggleWorkspaceStar(workspace.workspaceOriginalPath);
onToggleWorkspaceStar(workspace.workspaceId);
}}
title={workspace.isStarred ? 'Remove from Starred' : 'Add to Starred'}
>
@@ -236,7 +236,7 @@ export function SidebarWorkspaceItem({
!hasSelectedSession &&
'bg-yellow-50/50 hover:bg-yellow-100/50 dark:bg-yellow-900/10 dark:hover:bg-yellow-900/20',
)}
onClick={() => onToggleWorkspace(workspace.workspaceOriginalPath)}
onClick={() => onToggleWorkspace(workspace.workspaceId, workspace.workspaceOriginalPath)}
>
<div className="flex min-w-0 flex-1 items-center gap-3">
{isExpanded ? (
@@ -318,7 +318,7 @@ export function SidebarWorkspaceItem({
)}
onClick={(event) => {
event.stopPropagation();
onToggleWorkspaceStar(workspace.workspaceOriginalPath);
onToggleWorkspaceStar(workspace.workspaceId);
}}
title={workspace.isStarred ? 'Remove from Starred' : 'Add to Starred'}
>

View File

@@ -16,8 +16,8 @@ type SidebarWorkspaceListProps = {
isSavingSessionName: boolean;
onEditingWorkspaceNameChange: (name: string) => void;
onEditingSessionNameChange: (name: string) => void;
onToggleWorkspace: (workspacePath: string) => void;
onToggleWorkspaceStar: (workspacePath: string) => void;
onToggleWorkspace: (workspaceId: string, workspacePath: string) => void;
onToggleWorkspaceStar: (workspaceId: string) => void;
onStartWorkspaceRename: (workspace: WorkspaceRecord) => void;
onCancelWorkspaceRename: () => void;
onSaveWorkspaceRename: () => void;