mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-16 01:12:46 +00:00
refactor: setup sidebar workspace and session list
This commit is contained in:
@@ -51,6 +51,17 @@ export async function processCodexSessions() {
|
||||
}
|
||||
}
|
||||
|
||||
function getPathNumberVariants(value: number): string[] {
|
||||
const unpadded = String(value);
|
||||
const padded = unpadded.padStart(2, '0');
|
||||
|
||||
if (unpadded === padded) {
|
||||
return [unpadded];
|
||||
}
|
||||
|
||||
return [unpadded, padded];
|
||||
}
|
||||
|
||||
function buildCodexDatePathParts(createdAt: string): Array<{ year: string; month: string; day: string }> {
|
||||
const parsedDate = new Date(createdAt);
|
||||
if (Number.isNaN(parsedDate.getTime())) {
|
||||
@@ -59,25 +70,40 @@ function buildCodexDatePathParts(createdAt: string): Array<{ year: string; month
|
||||
|
||||
const localDate = {
|
||||
year: String(parsedDate.getFullYear()),
|
||||
month: String(parsedDate.getMonth() + 1),
|
||||
day: String(parsedDate.getDate()),
|
||||
month: parsedDate.getMonth() + 1,
|
||||
day: parsedDate.getDate(),
|
||||
};
|
||||
|
||||
const utcDate = {
|
||||
year: String(parsedDate.getUTCFullYear()),
|
||||
month: String(parsedDate.getUTCMonth() + 1),
|
||||
day: String(parsedDate.getUTCDate()),
|
||||
month: parsedDate.getUTCMonth() + 1,
|
||||
day: parsedDate.getUTCDate(),
|
||||
};
|
||||
|
||||
if (
|
||||
const rawDateParts =
|
||||
localDate.year === utcDate.year &&
|
||||
localDate.month === utcDate.month &&
|
||||
localDate.day === utcDate.day
|
||||
) {
|
||||
return [localDate];
|
||||
localDate.month === utcDate.month &&
|
||||
localDate.day === utcDate.day
|
||||
? [localDate]
|
||||
: [localDate, utcDate];
|
||||
|
||||
const uniqueDateParts = new Map<string, { year: string; month: string; day: string }>();
|
||||
for (const datePart of rawDateParts) {
|
||||
const monthVariants = getPathNumberVariants(datePart.month);
|
||||
const dayVariants = getPathNumberVariants(datePart.day);
|
||||
|
||||
for (const month of monthVariants) {
|
||||
for (const day of dayVariants) {
|
||||
uniqueDateParts.set(`${datePart.year}-${month}-${day}`, {
|
||||
year: datePart.year,
|
||||
month,
|
||||
day,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [localDate, utcDate];
|
||||
return [...uniqueDateParts.values()];
|
||||
}
|
||||
|
||||
async function removeFileIfExists(filePath: string): Promise<boolean> {
|
||||
|
||||
171
server/src/modules/sidebar/sidebar.routes.ts
Normal file
171
server/src/modules/sidebar/sidebar.routes.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import express, { type Request, type Response } from 'express';
|
||||
|
||||
import { authenticateToken } from '@/modules/auth/auth.middleware.js';
|
||||
import {
|
||||
deleteSessionById,
|
||||
deleteWorkspaceByPath,
|
||||
getWorkspaceSessionsCollection,
|
||||
updateSessionNameById,
|
||||
updateWorkspaceNameByPath,
|
||||
updateWorkspaceStarByPath,
|
||||
} from '@/modules/sidebar/sidebar.service.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const getTrimmedString = (value: unknown): string => {
|
||||
if (typeof value !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return value.trim();
|
||||
};
|
||||
|
||||
const getWorkspacePathFromBody = (req: Request): string => getTrimmedString(req.body?.workspacePath);
|
||||
|
||||
router.get(
|
||||
'/api/sidebar/get-workspaces-sessions',
|
||||
authenticateToken,
|
||||
async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const workspaces = getWorkspaceSessionsCollection();
|
||||
res.json({ workspaces });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to fetch workspaces';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/api/sidebar/update-workspace-star',
|
||||
authenticateToken,
|
||||
async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const workspacePath = getWorkspacePathFromBody(req);
|
||||
if (!workspacePath) {
|
||||
res.status(400).json({ error: 'workspacePath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const isStarred = updateWorkspaceStarByPath(workspacePath);
|
||||
res.json({ success: true, workspacePath, isStarred });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to update workspace star';
|
||||
const statusCode = message.toLowerCase().includes('not found') ? 404 : 500;
|
||||
res.status(statusCode).json({ error: message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/api/sidebar/update-workspace-custom-name',
|
||||
authenticateToken,
|
||||
async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const workspacePath = getWorkspacePathFromBody(req);
|
||||
if (!workspacePath) {
|
||||
res.status(400).json({ error: 'workspacePath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const customWorkspaceName = getTrimmedString(req.body?.workspaceCustomName);
|
||||
updateWorkspaceNameByPath(workspacePath, customWorkspaceName || null);
|
||||
|
||||
res.json({ success: true, workspacePath, workspaceCustomName: customWorkspaceName || null });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to update workspace name';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/api/sidebar/update-session-custom-name',
|
||||
authenticateToken,
|
||||
async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const sessionId = getTrimmedString(req.body?.sessionId);
|
||||
const sessionCustomName = getTrimmedString(req.body?.sessionCustomName);
|
||||
|
||||
if (!sessionId) {
|
||||
res.status(400).json({ error: 'sessionId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sessionCustomName) {
|
||||
res.status(400).json({ error: 'sessionCustomName is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (sessionCustomName.length > 500) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: 'sessionCustomName must not exceed 500 characters' });
|
||||
return;
|
||||
}
|
||||
|
||||
updateSessionNameById(sessionId, sessionCustomName);
|
||||
res.json({ success: true, sessionId, sessionCustomName });
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : 'Failed to update session name';
|
||||
const statusCode = message.toLowerCase().includes('not found') ? 404 : 500;
|
||||
res.status(statusCode).json({ error: message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/api/sidebar/delete-workspace',
|
||||
authenticateToken,
|
||||
async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const workspacePath = getWorkspacePathFromBody(req);
|
||||
if (!workspacePath) {
|
||||
res.status(400).json({ error: 'workspacePath is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await deleteWorkspaceByPath(workspacePath);
|
||||
res.json({
|
||||
success: true,
|
||||
workspacePath,
|
||||
...result,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to delete workspace';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/api/sidebar/delete-session',
|
||||
authenticateToken,
|
||||
async (req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
const sessionId = getTrimmedString(req.body?.sessionId);
|
||||
if (!sessionId) {
|
||||
res.status(400).json({ error: 'sessionId is required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await deleteSessionById(sessionId);
|
||||
if (!result.deleted) {
|
||||
res.status(404).json({ error: 'Session not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
sessionId,
|
||||
...result,
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Failed to delete session';
|
||||
res.status(500).json({ error: message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
export default router;
|
||||
247
server/src/modules/sidebar/sidebar.service.ts
Normal file
247
server/src/modules/sidebar/sidebar.service.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import { deleteClaudeSession } from '@/modules/providers/claude/claude.session-processor.js';
|
||||
import { deleteCodexSession } from '@/modules/providers/codex/codex.session-processor.js';
|
||||
import { deleteCursorSession } from '@/modules/providers/cursor/cursor.session-processor.js';
|
||||
import { deleteGeminiSession } from '@/modules/providers/gemini/gemini.session-processor.js';
|
||||
import { sessionsDb } from '@/shared/database/repositories/sessions.db.js';
|
||||
import { workspaceOriginalPathsDb } from '@/shared/database/repositories/workspace-original-paths.db.js';
|
||||
import type { SessionsRow } from '@/shared/database/types.js';
|
||||
|
||||
export type SidebarSessionRecord = {
|
||||
sessionId: string;
|
||||
id: string;
|
||||
provider: SessionsRow['provider'];
|
||||
customName: string | null;
|
||||
summary: string;
|
||||
workspacePath: string;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
lastActivity: string | null;
|
||||
};
|
||||
|
||||
export type SidebarWorkspaceRecord = {
|
||||
workspaceOriginalPath: string;
|
||||
workspaceCustomName: string | null;
|
||||
workspaceDisplayName: string;
|
||||
isStarred: boolean;
|
||||
lastActivity: string | null;
|
||||
sessions: SidebarSessionRecord[];
|
||||
};
|
||||
|
||||
export type DeleteSessionResult = {
|
||||
deleted: boolean;
|
||||
jsonlDeleted: boolean;
|
||||
};
|
||||
|
||||
export type DeleteWorkspaceResult = {
|
||||
deletedWorkspace: boolean;
|
||||
deletedSessionCount: number;
|
||||
jsonlDeletedCount: number;
|
||||
failedSessionFileDeletes: string[];
|
||||
};
|
||||
|
||||
type SessionDeletionTarget = Pick<SessionsRow, 'session_id' | 'provider' | 'workspace_path' | 'created_at'>;
|
||||
|
||||
const parseTimestamp = (timestamp: string | null | undefined): number => {
|
||||
if (!timestamp) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// SQLite CURRENT_TIMESTAMP is UTC but stored without timezone ("YYYY-MM-DD HH:MM:SS").
|
||||
// Normalize this format so parsing is always timezone-correct.
|
||||
const sqliteUtcPattern = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
|
||||
const normalizedTimestamp = sqliteUtcPattern.test(timestamp)
|
||||
? `${timestamp.replace(' ', 'T')}Z`
|
||||
: timestamp;
|
||||
|
||||
const parsed = new Date(normalizedTimestamp).getTime();
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
};
|
||||
|
||||
const toSidebarSessionRecord = (session: SessionsRow): SidebarSessionRecord => {
|
||||
const lastActivity = session.updated_at || session.created_at || null;
|
||||
|
||||
return {
|
||||
sessionId: session.session_id,
|
||||
id: session.session_id,
|
||||
provider: session.provider,
|
||||
customName: session.custom_name,
|
||||
summary: session.custom_name || 'Untitled Session',
|
||||
workspacePath: session.workspace_path,
|
||||
createdAt: session.created_at || null,
|
||||
updatedAt: session.updated_at || null,
|
||||
lastActivity,
|
||||
};
|
||||
};
|
||||
|
||||
const sortSessionsByLastActivity = (sessions: SidebarSessionRecord[]): SidebarSessionRecord[] =>
|
||||
[...sessions].sort((left, right) => {
|
||||
const timestampDifference =
|
||||
parseTimestamp(right.lastActivity) - parseTimestamp(left.lastActivity);
|
||||
|
||||
if (timestampDifference !== 0) {
|
||||
return timestampDifference;
|
||||
}
|
||||
|
||||
return right.sessionId.localeCompare(left.sessionId);
|
||||
});
|
||||
|
||||
const sortWorkspacesByLastActivity = (
|
||||
workspaces: SidebarWorkspaceRecord[],
|
||||
): SidebarWorkspaceRecord[] =>
|
||||
[...workspaces].sort((left, right) => {
|
||||
const timestampDifference =
|
||||
parseTimestamp(right.lastActivity) - parseTimestamp(left.lastActivity);
|
||||
|
||||
if (timestampDifference !== 0) {
|
||||
return timestampDifference;
|
||||
}
|
||||
|
||||
return left.workspaceDisplayName.localeCompare(right.workspaceDisplayName);
|
||||
});
|
||||
|
||||
const deleteSessionFileByProvider = async (
|
||||
session: SessionDeletionTarget,
|
||||
): Promise<boolean> => {
|
||||
switch (session.provider) {
|
||||
case 'claude':
|
||||
return deleteClaudeSession(session.session_id, session.workspace_path);
|
||||
case 'codex':
|
||||
return deleteCodexSession(session.session_id, session.created_at);
|
||||
case 'cursor':
|
||||
return deleteCursorSession(session.session_id, session.workspace_path);
|
||||
case 'gemini':
|
||||
return deleteGeminiSession(session.session_id);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const getWorkspaceSessionsCollection = (): SidebarWorkspaceRecord[] => {
|
||||
const workspaceRows = workspaceOriginalPathsDb.getWorkspacePaths();
|
||||
const sessionRows = sessionsDb.getAllSessions();
|
||||
const sessionsByWorkspace = new Map<string, SidebarSessionRecord[]>();
|
||||
|
||||
// Build grouped sessions once to keep the response shape deterministic.
|
||||
for (const sessionRow of sessionRows) {
|
||||
const existing = sessionsByWorkspace.get(sessionRow.workspace_path) || [];
|
||||
existing.push(toSidebarSessionRecord(sessionRow));
|
||||
sessionsByWorkspace.set(sessionRow.workspace_path, existing);
|
||||
}
|
||||
|
||||
const workspaceRecords = workspaceRows.map((workspaceRow) => {
|
||||
const sessions = sortSessionsByLastActivity(
|
||||
sessionsByWorkspace.get(workspaceRow.workspace_path) || [],
|
||||
);
|
||||
const lastActivity = sessions[0]?.lastActivity || null;
|
||||
|
||||
return {
|
||||
workspaceOriginalPath: workspaceRow.workspace_path,
|
||||
workspaceCustomName: workspaceRow.custom_workspace_name,
|
||||
workspaceDisplayName:
|
||||
workspaceRow.custom_workspace_name ||
|
||||
path.basename(workspaceRow.workspace_path) ||
|
||||
workspaceRow.workspace_path,
|
||||
isStarred: workspaceRow.isStarred === 1,
|
||||
lastActivity,
|
||||
sessions,
|
||||
};
|
||||
});
|
||||
|
||||
return sortWorkspacesByLastActivity(workspaceRecords);
|
||||
};
|
||||
|
||||
export const updateWorkspaceStarByPath = (workspacePath: string): boolean => {
|
||||
const workspaceRow = workspaceOriginalPathsDb.getWorkspacePath(workspacePath);
|
||||
if (!workspaceRow) {
|
||||
throw new Error('Workspace not found');
|
||||
}
|
||||
|
||||
const nextIsStarred = workspaceRow.isStarred !== 1;
|
||||
workspaceOriginalPathsDb.updateWorkspaceIsStarred(workspacePath, nextIsStarred);
|
||||
|
||||
return nextIsStarred;
|
||||
};
|
||||
|
||||
export const updateWorkspaceNameByPath = (
|
||||
workspacePath: string,
|
||||
workspaceCustomName: string | null,
|
||||
): void => {
|
||||
workspaceOriginalPathsDb.updateCustomWorkspaceName(workspacePath, workspaceCustomName);
|
||||
};
|
||||
|
||||
export const updateSessionNameById = (
|
||||
sessionId: string,
|
||||
sessionCustomName: string,
|
||||
): void => {
|
||||
const sessionMetadata = sessionsDb.getSessionById(sessionId);
|
||||
if (!sessionMetadata) {
|
||||
throw new Error('Session not found');
|
||||
}
|
||||
|
||||
sessionsDb.updateSessionCustomName(sessionId, sessionCustomName);
|
||||
};
|
||||
|
||||
export const deleteSessionById = async (
|
||||
sessionId: string,
|
||||
): Promise<DeleteSessionResult> => {
|
||||
const sessionMetadata = sessionsDb.getSessionById(sessionId);
|
||||
if (!sessionMetadata) {
|
||||
return {
|
||||
deleted: false,
|
||||
jsonlDeleted: false,
|
||||
};
|
||||
}
|
||||
|
||||
const jsonlDeleted = await deleteSessionFileByProvider({
|
||||
session_id: sessionMetadata.session_id,
|
||||
provider: sessionMetadata.provider,
|
||||
workspace_path: sessionMetadata.workspace_path,
|
||||
created_at: sessionMetadata.created_at,
|
||||
});
|
||||
|
||||
sessionsDb.deleteSession(sessionId);
|
||||
|
||||
return {
|
||||
deleted: true,
|
||||
jsonlDeleted,
|
||||
};
|
||||
};
|
||||
|
||||
export const deleteWorkspaceByPath = async (
|
||||
workspacePath: string,
|
||||
): Promise<DeleteWorkspaceResult> => {
|
||||
const sessionRows = sessionsDb.getSessionsByWorkspacePath(workspacePath);
|
||||
const failedSessionFileDeletes: string[] = [];
|
||||
let jsonlDeletedCount = 0;
|
||||
|
||||
// Remove all session files first, then clean up DB rows.
|
||||
for (const sessionRow of sessionRows) {
|
||||
try {
|
||||
const deleted = await deleteSessionFileByProvider({
|
||||
session_id: sessionRow.session_id,
|
||||
provider: sessionRow.provider,
|
||||
workspace_path: sessionRow.workspace_path,
|
||||
created_at: sessionRow.created_at,
|
||||
});
|
||||
|
||||
if (deleted) {
|
||||
jsonlDeletedCount += 1;
|
||||
}
|
||||
} catch {
|
||||
failedSessionFileDeletes.push(sessionRow.session_id);
|
||||
} finally {
|
||||
sessionsDb.deleteSession(sessionRow.session_id);
|
||||
}
|
||||
}
|
||||
|
||||
workspaceOriginalPathsDb.deleteWorkspacePath(workspacePath);
|
||||
|
||||
return {
|
||||
deletedWorkspace: true,
|
||||
deletedSessionCount: sessionRows.length,
|
||||
jsonlDeletedCount,
|
||||
failedSessionFileDeletes,
|
||||
};
|
||||
};
|
||||
@@ -61,6 +61,7 @@ const [
|
||||
geminiRoutes,
|
||||
pluginsRoutes,
|
||||
messagesRoutes,
|
||||
sidebarRoutes,
|
||||
projectsInlineRoutes,
|
||||
filesRoutes,
|
||||
sessionsInlineRoutes,
|
||||
@@ -85,6 +86,7 @@ const [
|
||||
importRoute('./modules/gemini/gemini.routes.js'),
|
||||
importRoute('./modules/plugins/plugins.routes.js'),
|
||||
importRoute('./modules/messages/messages.routes.js'),
|
||||
importRoute('./modules/sidebar/sidebar.routes.js'),
|
||||
importRoute('./modules/projects/projects.inline.routes.js'),
|
||||
importRoute('./modules/files/files.routes.js'),
|
||||
importRoute('./modules/sessions/sessions.inline.routes.js'),
|
||||
@@ -174,6 +176,9 @@ app.use('/api/plugins', authenticateToken, pluginsRoutes);
|
||||
// Unified session messages route (protected)
|
||||
app.use('/api/sessions', authenticateToken, messagesRoutes);
|
||||
|
||||
// Refactored sidebar routes (protected)
|
||||
app.use(sidebarRoutes);
|
||||
|
||||
// Agent API Routes (uses API key authentication)
|
||||
app.use('/api/agent', agentRoutes);
|
||||
|
||||
|
||||
@@ -49,6 +49,9 @@ export const runMigrations = (db: Database) => {
|
||||
db.exec(
|
||||
"CREATE INDEX IF NOT EXISTS idx_session_ids_lookup ON sessions(session_id)"
|
||||
);
|
||||
db.exec(
|
||||
"CREATE INDEX IF NOT EXISTS idx_sessions_workspace_path ON sessions(workspace_path)"
|
||||
);
|
||||
const sessionsTableInfo = db.prepare("PRAGMA table_info(sessions)").all() as { name: string }[];
|
||||
const sessionColumnNames = sessionsTableInfo.map((col) => col.name);
|
||||
addColumnToTableIfNotExists(db, "sessions", sessionColumnNames, "created_at", "DATETIME");
|
||||
@@ -66,6 +69,16 @@ export const runMigrations = (db: Database) => {
|
||||
"custom_workspace_name",
|
||||
"TEXT DEFAULT NULL",
|
||||
);
|
||||
addColumnToTableIfNotExists(
|
||||
db,
|
||||
"workspace_original_paths",
|
||||
workspaceOriginalPathsColumnNames,
|
||||
"isStarred",
|
||||
"BOOLEAN DEFAULT 0",
|
||||
);
|
||||
db.exec(
|
||||
"CREATE INDEX IF NOT EXISTS idx_workspace_original_paths_is_starred ON workspace_original_paths(isStarred)"
|
||||
);
|
||||
|
||||
db.exec(LAST_SCANNED_AT_SQL);
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ export const sessionsDb = {
|
||||
const db = getConnection();
|
||||
db.prepare(
|
||||
`UPDATE sessions
|
||||
SET custom_name = ?, updated_at = CURRENT_TIMESTAMP
|
||||
SET custom_name = ?
|
||||
WHERE session_id = ?`
|
||||
).run(customName, sessionId);
|
||||
},
|
||||
@@ -100,7 +100,7 @@ export const sessionsDb = {
|
||||
const db = getConnection();
|
||||
db.prepare(
|
||||
`UPDATE sessions
|
||||
SET custom_name = ?, updated_at = CURRENT_TIMESTAMP
|
||||
SET custom_name = ?
|
||||
WHERE session_id = ? AND provider = ?`
|
||||
).run(customName, sessionId, provider);
|
||||
},
|
||||
@@ -118,6 +118,27 @@ export const sessionsDb = {
|
||||
return row ?? null;
|
||||
},
|
||||
|
||||
getAllSessions(): SessionsRow[] {
|
||||
const db = getConnection();
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT session_id, provider, workspace_path, custom_name, created_at, updated_at
|
||||
FROM sessions`
|
||||
)
|
||||
.all() as SessionsRow[];
|
||||
},
|
||||
|
||||
getSessionsByWorkspacePath(workspacePath: string): SessionsRow[] {
|
||||
const db = getConnection();
|
||||
return db
|
||||
.prepare(
|
||||
`SELECT session_id, provider, workspace_path, custom_name, created_at, updated_at
|
||||
FROM sessions
|
||||
WHERE workspace_path = ?`
|
||||
)
|
||||
.all(workspacePath) as SessionsRow[];
|
||||
},
|
||||
|
||||
getSessionName(sessionId: string, provider: string): string | null {
|
||||
const db = getConnection();
|
||||
const row = db
|
||||
|
||||
@@ -16,6 +16,25 @@ export const workspaceOriginalPathsDb = {
|
||||
`).run(workspacePath, customWorkspaceName);
|
||||
},
|
||||
|
||||
getWorkspacePath(workspacePath: string): WorkspaceOriginalPathRow | null {
|
||||
const db = getConnection();
|
||||
const row = db.prepare(`
|
||||
SELECT workspace_path, custom_workspace_name, isStarred
|
||||
FROM workspace_original_paths
|
||||
WHERE workspace_path = ?
|
||||
`).get(workspacePath) as WorkspaceOriginalPathRow | undefined;
|
||||
|
||||
return row ?? null;
|
||||
},
|
||||
|
||||
getWorkspacePaths(): WorkspaceOriginalPathRow[] {
|
||||
const db = getConnection();
|
||||
return db.prepare(`
|
||||
SELECT workspace_path, custom_workspace_name, isStarred
|
||||
FROM workspace_original_paths
|
||||
`).all() as WorkspaceOriginalPathRow[];
|
||||
},
|
||||
|
||||
getCustomWorkspaceName(workspacePath: string): string | null {
|
||||
const db = getConnection();
|
||||
const row = db.prepare(`
|
||||
@@ -35,4 +54,21 @@ export const workspaceOriginalPathsDb = {
|
||||
ON CONFLICT(workspace_path) DO UPDATE SET custom_workspace_name = excluded.custom_workspace_name
|
||||
`).run(workspacePath, customWorkspaceName);
|
||||
},
|
||||
|
||||
updateWorkspaceIsStarred(workspacePath: string, isStarred: boolean): void {
|
||||
const db = getConnection();
|
||||
db.prepare(`
|
||||
UPDATE workspace_original_paths
|
||||
SET isStarred = ?
|
||||
WHERE workspace_path = ?
|
||||
`).run(isStarred ? 1 : 0, workspacePath);
|
||||
},
|
||||
|
||||
deleteWorkspacePath(workspacePath: string): void {
|
||||
const db = getConnection();
|
||||
db.prepare(`
|
||||
DELETE FROM workspace_original_paths
|
||||
WHERE workspace_path = ?
|
||||
`).run(workspacePath);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
custom_workspace_name TEXT DEFAULT NULL
|
||||
custom_workspace_name TEXT DEFAULT NULL,
|
||||
isStarred BOOLEAN DEFAULT 0
|
||||
);
|
||||
`
|
||||
|
||||
@@ -135,8 +136,10 @@ CREATE INDEX IF NOT EXISTS idx_push_subscriptions_user_id ON push_subscriptions(
|
||||
|
||||
${SESSIONS_TABLE_SCHEMA_SQL}
|
||||
CREATE INDEX IF NOT EXISTS idx_session_ids_lookup ON sessions(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_workspace_path ON sessions(workspace_path);
|
||||
|
||||
${WORK_SPACE_PATH_SQL}
|
||||
CREATE INDEX IF NOT EXISTS idx_workspace_original_paths_is_starred ON workspace_original_paths(isStarred);
|
||||
|
||||
${LAST_SCANNED_AT_SQL}
|
||||
|
||||
|
||||
@@ -130,6 +130,7 @@ export type SessionWithSummary = {
|
||||
export type WorkspaceOriginalPathRow = {
|
||||
workspace_path: string;
|
||||
custom_workspace_name: string | null;
|
||||
isStarred: number; // SQLite boolean: 0 | 1
|
||||
};
|
||||
|
||||
|
||||
|
||||
@@ -14,10 +14,14 @@ const router = createBrowserRouter([
|
||||
path: "/",
|
||||
element: <RootLayout />, // The layout wraps all children
|
||||
children: [
|
||||
{
|
||||
{
|
||||
path: "/",
|
||||
element: <Home />,
|
||||
},
|
||||
{
|
||||
path: "/session/:sessionId",
|
||||
element: <SessionContent />,
|
||||
},
|
||||
{
|
||||
path: "/sessions/:sessionId",
|
||||
element: <SessionContent />,
|
||||
|
||||
@@ -1,23 +1,120 @@
|
||||
import type { WorkspaceRecord } from '@/components/refactored/sidebar/types';
|
||||
import { authenticatedFetch } from '@/utils/api';
|
||||
import type { Project } from '@/types/app';
|
||||
|
||||
/**
|
||||
* Data Extractor layer
|
||||
* Handles fetching workspaces from the API and formatting them.
|
||||
*/
|
||||
export const fetchWorkspaces = async (): Promise<Project[]> => {
|
||||
const SIDEBAR_ENDPOINTS = {
|
||||
getWorkspaceSessions: '/api/sidebar/get-workspaces-sessions',
|
||||
updateWorkspaceStar: '/api/sidebar/update-workspace-star',
|
||||
updateWorkspaceCustomName: '/api/sidebar/update-workspace-custom-name',
|
||||
updateSessionCustomName: '/api/sidebar/update-session-custom-name',
|
||||
deleteWorkspace: '/api/sidebar/delete-workspace',
|
||||
deleteSession: '/api/sidebar/delete-session',
|
||||
} as const;
|
||||
|
||||
const parseJsonSafely = async <T>(response: Response): Promise<T | null> => {
|
||||
try {
|
||||
const response = await authenticatedFetch('/api/projects');
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch workspaces: ${response.statusText}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
|
||||
// Normalize response formats depending on the actual backend implementation
|
||||
return data.projects || data.workspaces || data || [];
|
||||
} catch (error) {
|
||||
console.error('Error fetching workspaces:', error);
|
||||
// Return empty array to gracefully handle failure
|
||||
return [];
|
||||
return (await response.json()) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const getErrorMessage = (fallbackMessage: string, payload: unknown): string => {
|
||||
if (
|
||||
payload &&
|
||||
typeof payload === 'object' &&
|
||||
'error' in payload &&
|
||||
typeof (payload as { error?: unknown }).error === 'string'
|
||||
) {
|
||||
return (payload as { error: string }).error;
|
||||
}
|
||||
|
||||
return fallbackMessage;
|
||||
};
|
||||
|
||||
export const getWorkspaceSessions = async (): Promise<WorkspaceRecord[]> => {
|
||||
const response = await authenticatedFetch(SIDEBAR_ENDPOINTS.getWorkspaceSessions);
|
||||
const payload = await parseJsonSafely<{ workspaces?: WorkspaceRecord[]; error?: string }>(response);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(getErrorMessage('Failed to fetch workspaces', payload));
|
||||
}
|
||||
|
||||
return payload?.workspaces || [];
|
||||
};
|
||||
|
||||
export const updateWorkspaceStar = async (
|
||||
workspacePath: string,
|
||||
): Promise<{ workspacePath: string; isStarred: boolean }> => {
|
||||
const response = await authenticatedFetch(SIDEBAR_ENDPOINTS.updateWorkspaceStar, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ workspacePath }),
|
||||
});
|
||||
const payload = await parseJsonSafely<{
|
||||
workspacePath?: string;
|
||||
isStarred?: boolean;
|
||||
error?: string;
|
||||
}>(response);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(getErrorMessage('Failed to update workspace star', payload));
|
||||
}
|
||||
|
||||
return {
|
||||
workspacePath: payload?.workspacePath || workspacePath,
|
||||
isStarred: Boolean(payload?.isStarred),
|
||||
};
|
||||
};
|
||||
|
||||
export const updateWorkspaceCustomName = async (
|
||||
workspacePath: string,
|
||||
workspaceCustomName: string | null,
|
||||
): Promise<void> => {
|
||||
const response = await authenticatedFetch(SIDEBAR_ENDPOINTS.updateWorkspaceCustomName, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ workspacePath, workspaceCustomName }),
|
||||
});
|
||||
const payload = await parseJsonSafely<{ error?: string }>(response);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(getErrorMessage('Failed to update workspace name', payload));
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteWorkspaceByPath = async (workspacePath: string): Promise<void> => {
|
||||
const response = await authenticatedFetch(SIDEBAR_ENDPOINTS.deleteWorkspace, {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ workspacePath }),
|
||||
});
|
||||
const payload = await parseJsonSafely<{ error?: string }>(response);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(getErrorMessage('Failed to delete workspace', payload));
|
||||
}
|
||||
};
|
||||
|
||||
export const updateSessionCustomName = async (
|
||||
sessionId: string,
|
||||
sessionCustomName: string,
|
||||
): Promise<void> => {
|
||||
const response = await authenticatedFetch(SIDEBAR_ENDPOINTS.updateSessionCustomName, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ sessionId, sessionCustomName }),
|
||||
});
|
||||
const payload = await parseJsonSafely<{ error?: string }>(response);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(getErrorMessage('Failed to update session name', payload));
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteSessionById = async (sessionId: string): Promise<void> => {
|
||||
const response = await authenticatedFetch(SIDEBAR_ENDPOINTS.deleteSession, {
|
||||
method: 'DELETE',
|
||||
body: JSON.stringify({ sessionId }),
|
||||
});
|
||||
const payload = await parseJsonSafely<{ error?: string }>(response);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(getErrorMessage('Failed to delete session', payload));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,33 +1,327 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { fetchWorkspaces } from '../data/workspacesApi';
|
||||
import type { Project } from '@/types/app';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
import {
|
||||
deleteSessionById,
|
||||
deleteWorkspaceByPath,
|
||||
getWorkspaceSessions,
|
||||
updateSessionCustomName,
|
||||
updateWorkspaceCustomName,
|
||||
updateWorkspaceStar,
|
||||
} from '@/components/refactored/sidebar/data/workspacesApi';
|
||||
import type {
|
||||
SearchMode,
|
||||
SessionDeleteTarget,
|
||||
WorkspaceDeleteTarget,
|
||||
WorkspaceRecord,
|
||||
WorkspaceSession,
|
||||
} from '@/components/refactored/sidebar/types';
|
||||
import { filterWorkspacesBySearch } from '@/components/refactored/sidebar/utils/search';
|
||||
import {
|
||||
getSessionDisplayName,
|
||||
getWorkspaceDisplayName,
|
||||
sortWorkspacesByLastActivity,
|
||||
splitWorkspacesByStarred,
|
||||
} from '@/components/refactored/sidebar/utils/workspaceTransforms';
|
||||
|
||||
const SESSION_ROUTE_PATTERN = /^\/session\/([^/]+)$/;
|
||||
const LEGACY_SESSION_ROUTE_PATTERN = /^\/sessions\/([^/]+)$/;
|
||||
|
||||
const extractSessionIdFromPathname = (pathname: string): string | null => {
|
||||
const sessionMatch = pathname.match(SESSION_ROUTE_PATTERN);
|
||||
if (sessionMatch?.[1]) {
|
||||
return decodeURIComponent(sessionMatch[1]);
|
||||
}
|
||||
|
||||
const legacySessionMatch = pathname.match(LEGACY_SESSION_ROUTE_PATTERN);
|
||||
if (legacySessionMatch?.[1]) {
|
||||
return decodeURIComponent(legacySessionMatch[1]);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook layer (The Manager)
|
||||
* Manages fetching workspaces and loading states.
|
||||
* Owns sidebar workspace/session state and coordinates UI actions.
|
||||
*/
|
||||
export const useWorkspaces = () => {
|
||||
const [workspaces, setWorkspaces] = useState<Project[]>([]);
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const [workspaces, setWorkspaces] = useState<WorkspaceRecord[]>([]);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [searchMode, setSearchMode] = useState<SearchMode>('projects');
|
||||
const [searchFilter, setSearchFilter] = useState('');
|
||||
const [expandedWorkspaces, setExpandedWorkspaces] = useState<Set<string>>(new Set());
|
||||
const [editingWorkspacePath, setEditingWorkspacePath] = useState<string | null>(null);
|
||||
const [editingWorkspaceName, setEditingWorkspaceName] = useState('');
|
||||
const [workspaceDeleteTarget, setWorkspaceDeleteTarget] = useState<WorkspaceDeleteTarget | null>(null);
|
||||
const [sessionDeleteTarget, setSessionDeleteTarget] = useState<SessionDeleteTarget | null>(null);
|
||||
const [isSavingWorkspaceName, setIsSavingWorkspaceName] = useState(false);
|
||||
const [editingSessionId, setEditingSessionId] = useState<string | null>(null);
|
||||
const [editingSessionName, setEditingSessionName] = useState('');
|
||||
const [isSavingSessionName, setIsSavingSessionName] = useState(false);
|
||||
|
||||
const selectedSessionId = useMemo(
|
||||
() => extractSessionIdFromPathname(location.pathname),
|
||||
[location.pathname],
|
||||
);
|
||||
|
||||
const refreshWorkspaces = useCallback(async () => {
|
||||
setIsRefreshing(true);
|
||||
try {
|
||||
const data = await fetchWorkspaces();
|
||||
setWorkspaces(data);
|
||||
const fetchedWorkspaces = await getWorkspaceSessions();
|
||||
setWorkspaces(sortWorkspacesByLastActivity(fetchedWorkspaces));
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh workspaces:', error);
|
||||
setWorkspaces([]);
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch on mount
|
||||
useEffect(() => {
|
||||
refreshWorkspaces();
|
||||
void refreshWorkspaces();
|
||||
}, [refreshWorkspaces]);
|
||||
|
||||
const filteredWorkspaces = useMemo(
|
||||
() => filterWorkspacesBySearch(workspaces, searchMode, searchFilter),
|
||||
[searchFilter, searchMode, workspaces],
|
||||
);
|
||||
|
||||
const workspaceGroups = useMemo(
|
||||
() => splitWorkspacesByStarred(filteredWorkspaces),
|
||||
[filteredWorkspaces],
|
||||
);
|
||||
|
||||
const toggleWorkspace = useCallback((workspacePath: string) => {
|
||||
setExpandedWorkspaces((previousSet) => {
|
||||
const nextSet = new Set(previousSet);
|
||||
|
||||
if (nextSet.has(workspacePath)) {
|
||||
nextSet.delete(workspacePath);
|
||||
} else {
|
||||
nextSet.add(workspacePath);
|
||||
}
|
||||
|
||||
return nextSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const openSession = useCallback(
|
||||
(workspacePath: string, sessionId: string) => {
|
||||
setExpandedWorkspaces((previousSet) => {
|
||||
const nextSet = new Set(previousSet);
|
||||
nextSet.add(workspacePath);
|
||||
return nextSet;
|
||||
});
|
||||
navigate(`/session/${encodeURIComponent(sessionId)}`);
|
||||
},
|
||||
[navigate],
|
||||
);
|
||||
|
||||
const openNewSession = useCallback(() => {
|
||||
navigate('/');
|
||||
}, [navigate]);
|
||||
|
||||
const toggleWorkspaceStar = useCallback(async (workspacePath: string) => {
|
||||
try {
|
||||
await updateWorkspaceStar(workspacePath);
|
||||
await refreshWorkspaces();
|
||||
} catch (error) {
|
||||
console.error('Failed to update workspace star:', error);
|
||||
}
|
||||
}, [refreshWorkspaces]);
|
||||
|
||||
const startWorkspaceRename = useCallback((workspace: WorkspaceRecord) => {
|
||||
setEditingWorkspacePath(workspace.workspaceOriginalPath);
|
||||
setEditingWorkspaceName(workspace.workspaceCustomName || '');
|
||||
}, []);
|
||||
|
||||
const cancelWorkspaceRename = useCallback(() => {
|
||||
setEditingWorkspacePath(null);
|
||||
setEditingWorkspaceName('');
|
||||
}, []);
|
||||
|
||||
const saveWorkspaceRename = useCallback(async () => {
|
||||
if (!editingWorkspacePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSavingWorkspaceName(true);
|
||||
try {
|
||||
const trimmedName = editingWorkspaceName.trim();
|
||||
await updateWorkspaceCustomName(editingWorkspacePath, trimmedName || null);
|
||||
await refreshWorkspaces();
|
||||
cancelWorkspaceRename();
|
||||
} catch (error) {
|
||||
console.error('Failed to update workspace name:', error);
|
||||
} finally {
|
||||
setIsSavingWorkspaceName(false);
|
||||
}
|
||||
}, [
|
||||
cancelWorkspaceRename,
|
||||
editingWorkspaceName,
|
||||
editingWorkspacePath,
|
||||
refreshWorkspaces,
|
||||
]);
|
||||
|
||||
const requestWorkspaceDelete = useCallback((workspace: WorkspaceRecord) => {
|
||||
setWorkspaceDeleteTarget({
|
||||
workspacePath: workspace.workspaceOriginalPath,
|
||||
workspaceName: getWorkspaceDisplayName(workspace),
|
||||
sessionCount: workspace.sessions.length,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const cancelWorkspaceDelete = useCallback(() => {
|
||||
setWorkspaceDeleteTarget(null);
|
||||
}, []);
|
||||
|
||||
const confirmWorkspaceDelete = useCallback(async () => {
|
||||
if (!workspaceDeleteTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deletingWorkspacePath = workspaceDeleteTarget.workspacePath;
|
||||
setWorkspaceDeleteTarget(null);
|
||||
try {
|
||||
await deleteWorkspaceByPath(deletingWorkspacePath);
|
||||
|
||||
// If the current session belonged to the deleted workspace, reset to root.
|
||||
const hadSelectedSession = workspaces.some(
|
||||
(workspace) =>
|
||||
workspace.workspaceOriginalPath === deletingWorkspacePath &&
|
||||
workspace.sessions.some((session) => session.sessionId === selectedSessionId),
|
||||
);
|
||||
if (hadSelectedSession) {
|
||||
navigate('/');
|
||||
}
|
||||
|
||||
await refreshWorkspaces();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete workspace:', error);
|
||||
}
|
||||
}, [
|
||||
navigate,
|
||||
refreshWorkspaces,
|
||||
selectedSessionId,
|
||||
workspaceDeleteTarget,
|
||||
workspaces,
|
||||
]);
|
||||
|
||||
const requestSessionDelete = useCallback(
|
||||
(workspacePath: string, session: WorkspaceSession) => {
|
||||
setSessionDeleteTarget({
|
||||
sessionId: session.sessionId,
|
||||
sessionName: getSessionDisplayName(session),
|
||||
workspacePath,
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const cancelSessionDelete = useCallback(() => {
|
||||
setSessionDeleteTarget(null);
|
||||
}, []);
|
||||
|
||||
const startSessionRename = useCallback((session: WorkspaceSession) => {
|
||||
setEditingSessionId(session.sessionId);
|
||||
setEditingSessionName(getSessionDisplayName(session));
|
||||
}, []);
|
||||
|
||||
const cancelSessionRename = useCallback(() => {
|
||||
setEditingSessionId(null);
|
||||
setEditingSessionName('');
|
||||
}, []);
|
||||
|
||||
const saveSessionRename = useCallback(async () => {
|
||||
if (!editingSessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedName = editingSessionName.trim();
|
||||
if (!trimmedName) {
|
||||
cancelSessionRename();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSavingSessionName(true);
|
||||
try {
|
||||
await updateSessionCustomName(editingSessionId, trimmedName);
|
||||
await refreshWorkspaces();
|
||||
cancelSessionRename();
|
||||
} catch (error) {
|
||||
console.error('Failed to rename session:', error);
|
||||
} finally {
|
||||
setIsSavingSessionName(false);
|
||||
}
|
||||
}, [
|
||||
cancelSessionRename,
|
||||
editingSessionId,
|
||||
editingSessionName,
|
||||
refreshWorkspaces,
|
||||
]);
|
||||
|
||||
const confirmSessionDelete = useCallback(async () => {
|
||||
if (!sessionDeleteTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deletingSessionId = sessionDeleteTarget.sessionId;
|
||||
setSessionDeleteTarget(null);
|
||||
|
||||
try {
|
||||
await deleteSessionById(deletingSessionId);
|
||||
|
||||
if (selectedSessionId === deletingSessionId) {
|
||||
navigate('/');
|
||||
}
|
||||
|
||||
await refreshWorkspaces();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete session:', error);
|
||||
}
|
||||
}, [navigate, refreshWorkspaces, selectedSessionId, sessionDeleteTarget]);
|
||||
|
||||
return {
|
||||
workspaces,
|
||||
starredWorkspaces: workspaceGroups.starred,
|
||||
unstarredWorkspaces: workspaceGroups.unstarred,
|
||||
isRefreshing,
|
||||
refreshWorkspaces,
|
||||
searchMode,
|
||||
setSearchMode,
|
||||
searchFilter,
|
||||
setSearchFilter,
|
||||
selectedSessionId,
|
||||
expandedWorkspaces,
|
||||
toggleWorkspace,
|
||||
openSession,
|
||||
openNewSession,
|
||||
editingWorkspacePath,
|
||||
editingWorkspaceName,
|
||||
isSavingWorkspaceName,
|
||||
editingSessionId,
|
||||
editingSessionName,
|
||||
isSavingSessionName,
|
||||
setEditingWorkspaceName,
|
||||
setEditingSessionName,
|
||||
startWorkspaceRename,
|
||||
cancelWorkspaceRename,
|
||||
saveWorkspaceRename,
|
||||
startSessionRename,
|
||||
cancelSessionRename,
|
||||
saveSessionRename,
|
||||
toggleWorkspaceStar,
|
||||
workspaceDeleteTarget,
|
||||
sessionDeleteTarget,
|
||||
requestWorkspaceDelete,
|
||||
cancelWorkspaceDelete,
|
||||
confirmWorkspaceDelete,
|
||||
requestSessionDelete,
|
||||
cancelSessionDelete,
|
||||
confirmSessionDelete,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1 +1,41 @@
|
||||
export type SearchMode = 'projects' | 'conversations';
|
||||
import type { SessionProvider } from '@/types/app';
|
||||
|
||||
export type SearchMode = 'projects' | 'conversations';
|
||||
|
||||
export type WorkspaceSession = {
|
||||
sessionId: string;
|
||||
id: string;
|
||||
provider: SessionProvider;
|
||||
customName: string | null;
|
||||
summary: string;
|
||||
workspacePath: string;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
lastActivity: string | null;
|
||||
};
|
||||
|
||||
export type WorkspaceRecord = {
|
||||
workspaceOriginalPath: string;
|
||||
workspaceCustomName: string | null;
|
||||
workspaceDisplayName: string;
|
||||
isStarred: boolean;
|
||||
lastActivity: string | null;
|
||||
sessions: WorkspaceSession[];
|
||||
};
|
||||
|
||||
export type WorkspaceDeleteTarget = {
|
||||
workspacePath: string;
|
||||
workspaceName: string;
|
||||
sessionCount: number;
|
||||
};
|
||||
|
||||
export type SessionDeleteTarget = {
|
||||
sessionId: string;
|
||||
sessionName: string;
|
||||
workspacePath: string;
|
||||
};
|
||||
|
||||
export type WorkspaceGroups = {
|
||||
starred: WorkspaceRecord[];
|
||||
unstarred: WorkspaceRecord[];
|
||||
};
|
||||
|
||||
@@ -1,16 +1,59 @@
|
||||
import type { Project } from '@/types/app';
|
||||
import type {
|
||||
SearchMode,
|
||||
WorkspaceRecord,
|
||||
} from '@/components/refactored/sidebar/types';
|
||||
|
||||
const includesSearch = (value: string | null | undefined, searchText: string): boolean =>
|
||||
(value || '').toLowerCase().includes(searchText);
|
||||
|
||||
/**
|
||||
* Filters workspaces/projects by matching the search string against
|
||||
* both `displayName` and `name` (case-insensitive substring match).
|
||||
* Filters workspaces and sessions based on search mode.
|
||||
* In conversations mode, sessions are filtered while preserving workspace context.
|
||||
*/
|
||||
export const filterWorkspacesByName = (workspaces: Project[], filter: string): Project[] => {
|
||||
const normalized = filter.trim().toLowerCase();
|
||||
if (!normalized) return workspaces;
|
||||
export const filterWorkspacesBySearch = (
|
||||
workspaces: WorkspaceRecord[],
|
||||
searchMode: SearchMode,
|
||||
filterText: string,
|
||||
): WorkspaceRecord[] => {
|
||||
const normalizedFilter = filterText.trim().toLowerCase();
|
||||
if (!normalizedFilter) {
|
||||
return workspaces;
|
||||
}
|
||||
|
||||
return workspaces.filter((project) => {
|
||||
const displayName = (project.displayName || project.name).toLowerCase();
|
||||
const projectName = project.name.toLowerCase();
|
||||
return displayName.includes(normalized) || projectName.includes(normalized);
|
||||
});
|
||||
if (searchMode === 'projects') {
|
||||
return workspaces.filter((workspace) =>
|
||||
includesSearch(workspace.workspaceDisplayName, normalizedFilter) ||
|
||||
includesSearch(workspace.workspaceCustomName, normalizedFilter) ||
|
||||
includesSearch(workspace.workspaceOriginalPath, normalizedFilter),
|
||||
);
|
||||
}
|
||||
|
||||
return workspaces
|
||||
.map((workspace) => {
|
||||
const workspaceMatches =
|
||||
includesSearch(workspace.workspaceDisplayName, normalizedFilter) ||
|
||||
includesSearch(workspace.workspaceCustomName, normalizedFilter) ||
|
||||
includesSearch(workspace.workspaceOriginalPath, normalizedFilter);
|
||||
|
||||
if (workspaceMatches) {
|
||||
return workspace;
|
||||
}
|
||||
|
||||
const matchingSessions = workspace.sessions.filter((session) =>
|
||||
includesSearch(session.customName, normalizedFilter) ||
|
||||
includesSearch(session.summary, normalizedFilter) ||
|
||||
includesSearch(session.sessionId, normalizedFilter) ||
|
||||
includesSearch(session.provider, normalizedFilter),
|
||||
);
|
||||
|
||||
if (matchingSessions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...workspace,
|
||||
sessions: matchingSessions,
|
||||
};
|
||||
})
|
||||
.filter((workspace): workspace is WorkspaceRecord => Boolean(workspace));
|
||||
};
|
||||
|
||||
123
src/components/refactored/sidebar/utils/workspaceTransforms.ts
Normal file
123
src/components/refactored/sidebar/utils/workspaceTransforms.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import type {
|
||||
WorkspaceGroups,
|
||||
WorkspaceRecord,
|
||||
WorkspaceSession,
|
||||
} from '@/components/refactored/sidebar/types';
|
||||
|
||||
const parseTimestamp = (timestamp: string | null | undefined): number => {
|
||||
if (!timestamp) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// SQLite CURRENT_TIMESTAMP is UTC but does not include timezone metadata.
|
||||
// Convert it to an explicit UTC ISO-like string before parsing.
|
||||
const sqliteUtcPattern = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/;
|
||||
const normalizedTimestamp = sqliteUtcPattern.test(timestamp)
|
||||
? `${timestamp.replace(' ', 'T')}Z`
|
||||
: timestamp;
|
||||
|
||||
const parsed = new Date(normalizedTimestamp).getTime();
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
};
|
||||
|
||||
export const sortSessionsByLastActivity = (
|
||||
sessions: WorkspaceSession[],
|
||||
): WorkspaceSession[] =>
|
||||
[...sessions].sort((left, right) => {
|
||||
const timestampDiff =
|
||||
parseTimestamp(right.lastActivity) - parseTimestamp(left.lastActivity);
|
||||
|
||||
if (timestampDiff !== 0) {
|
||||
return timestampDiff;
|
||||
}
|
||||
|
||||
return right.sessionId.localeCompare(left.sessionId);
|
||||
});
|
||||
|
||||
export const sortWorkspacesByLastActivity = (
|
||||
workspaces: WorkspaceRecord[],
|
||||
): WorkspaceRecord[] =>
|
||||
[...workspaces].sort((left, right) => {
|
||||
const timestampDiff =
|
||||
parseTimestamp(right.lastActivity) - parseTimestamp(left.lastActivity);
|
||||
|
||||
if (timestampDiff !== 0) {
|
||||
return timestampDiff;
|
||||
}
|
||||
|
||||
return left.workspaceDisplayName.localeCompare(right.workspaceDisplayName);
|
||||
});
|
||||
|
||||
export const splitWorkspacesByStarred = (
|
||||
workspaces: WorkspaceRecord[],
|
||||
): WorkspaceGroups => {
|
||||
const starred = workspaces.filter((workspace) => workspace.isStarred);
|
||||
const unstarred = workspaces.filter((workspace) => !workspace.isStarred);
|
||||
|
||||
return {
|
||||
starred: sortWorkspacesByLastActivity(starred),
|
||||
unstarred: sortWorkspacesByLastActivity(unstarred),
|
||||
};
|
||||
};
|
||||
|
||||
export const getWorkspaceDisplayName = (workspace: WorkspaceRecord): string =>
|
||||
workspace.workspaceCustomName ||
|
||||
workspace.workspaceDisplayName ||
|
||||
workspace.workspaceOriginalPath;
|
||||
|
||||
export const getSessionDisplayName = (session: WorkspaceSession): string =>
|
||||
session.customName || session.summary || session.sessionId || 'Untitled Session';
|
||||
|
||||
export const formatRelativeTime = (timestamp: string | null | undefined): string => {
|
||||
if (!timestamp) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
const parsedTime = parseTimestamp(timestamp);
|
||||
if (!Number.isFinite(parsedTime)) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
const diffMs = Math.max(0, Date.now() - parsedTime);
|
||||
const diffMinutes = Math.floor(diffMs / (1000 * 60));
|
||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
const diffMonths = Math.floor(diffDays / 30);
|
||||
const diffYears = Math.floor(diffDays / 365);
|
||||
|
||||
if (diffMinutes < 1) {
|
||||
return '1m';
|
||||
}
|
||||
|
||||
if (diffMinutes < 60) {
|
||||
return `${diffMinutes}m`;
|
||||
}
|
||||
|
||||
if (diffHours < 24) {
|
||||
return `${diffHours}h`;
|
||||
}
|
||||
|
||||
if (diffDays < 30) {
|
||||
return `${diffDays}d`;
|
||||
}
|
||||
|
||||
if (diffDays < 365) {
|
||||
return `${diffMonths}mo`;
|
||||
}
|
||||
|
||||
return `${diffYears}y`;
|
||||
};
|
||||
|
||||
export const isRecentActivity = (timestamp: string | null | undefined): boolean => {
|
||||
if (!timestamp) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsedTime = parseTimestamp(timestamp);
|
||||
if (!Number.isFinite(parsedTime)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const diffMs = Math.max(0, Date.now() - parsedTime);
|
||||
return diffMs <= 10 * 60 * 1000;
|
||||
};
|
||||
@@ -1,17 +1,69 @@
|
||||
import { PanelRightOpen } from 'lucide-react';
|
||||
import { useSidebarSettings } from '../hooks/useSidebarSettings';
|
||||
import { useWorkspaces } from '../hooks/useWorkspaces';
|
||||
import { useSidebarModals } from '../hooks/useSidebarModals';
|
||||
import SidebarHeader from './SidebarHeader';
|
||||
import { useSidebarSettings } from '@/components/refactored/sidebar/hooks/useSidebarSettings';
|
||||
import { useSidebarModals } from '@/components/refactored/sidebar/hooks/useSidebarModals';
|
||||
import { useWorkspaces } from '@/components/refactored/sidebar/hooks/useWorkspaces';
|
||||
import SidebarHeader from '@/components/refactored/sidebar/view/SidebarHeader';
|
||||
import { SidebarDeleteModals } from '@/components/refactored/sidebar/view/SidebarDeleteModals';
|
||||
import { SidebarWorkspaceList } from '@/components/refactored/sidebar/view/SidebarWorkspaceList';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/shared/view/ui';
|
||||
import ProjectCreationWizard from '@/components/project-creation-wizard';
|
||||
|
||||
export function Sidebar() {
|
||||
const { isCollapsed, toggleCollapse, setCollapsed } = useSidebarSettings();
|
||||
const { workspaces, isRefreshing, refreshWorkspaces } = useWorkspaces();
|
||||
const {
|
||||
workspaces,
|
||||
starredWorkspaces,
|
||||
unstarredWorkspaces,
|
||||
isRefreshing,
|
||||
refreshWorkspaces,
|
||||
searchMode,
|
||||
setSearchMode,
|
||||
searchFilter,
|
||||
setSearchFilter,
|
||||
selectedSessionId,
|
||||
expandedWorkspaces,
|
||||
toggleWorkspace,
|
||||
openSession,
|
||||
openNewSession,
|
||||
editingWorkspacePath,
|
||||
editingWorkspaceName,
|
||||
isSavingWorkspaceName,
|
||||
editingSessionId,
|
||||
editingSessionName,
|
||||
isSavingSessionName,
|
||||
setEditingWorkspaceName,
|
||||
setEditingSessionName,
|
||||
startWorkspaceRename,
|
||||
cancelWorkspaceRename,
|
||||
saveWorkspaceRename,
|
||||
startSessionRename,
|
||||
cancelSessionRename,
|
||||
saveSessionRename,
|
||||
toggleWorkspaceStar,
|
||||
workspaceDeleteTarget,
|
||||
sessionDeleteTarget,
|
||||
requestWorkspaceDelete,
|
||||
cancelWorkspaceDelete,
|
||||
confirmWorkspaceDelete,
|
||||
requestSessionDelete,
|
||||
cancelSessionDelete,
|
||||
confirmSessionDelete,
|
||||
} = useWorkspaces();
|
||||
const { showNewProject, openNewProject, closeNewProject } = useSidebarModals();
|
||||
|
||||
const handleSessionDeleteRequest = (workspacePath: string, sessionId: string) => {
|
||||
const workspace = workspaces.find(
|
||||
(workspaceItem) => workspaceItem.workspaceOriginalPath === workspacePath,
|
||||
);
|
||||
const session = workspace?.sessions.find((item) => item.sessionId === sessionId);
|
||||
if (!workspace || !session) {
|
||||
return;
|
||||
}
|
||||
|
||||
requestSessionDelete(workspacePath, session);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<>
|
||||
@@ -38,12 +90,41 @@ export function Sidebar() {
|
||||
isRefreshing={isRefreshing}
|
||||
onRefresh={refreshWorkspaces}
|
||||
onNewProject={openNewProject}
|
||||
searchMode={searchMode}
|
||||
onSearchModeChange={setSearchMode}
|
||||
searchFilter={searchFilter}
|
||||
onSearchFilterChange={setSearchFilter}
|
||||
/>
|
||||
{/* Placeholder for the rest of the sidebar content */}
|
||||
{!isCollapsed && (
|
||||
<div className="flex-1 overflow-y-auto overscroll-contain">
|
||||
{/* Future list component will go here */}
|
||||
{/* Can pass workspaces to the future list component as props */}
|
||||
<SidebarWorkspaceList
|
||||
workspacesCount={workspaces.length}
|
||||
searchFilter={searchFilter}
|
||||
starredWorkspaces={starredWorkspaces}
|
||||
unstarredWorkspaces={unstarredWorkspaces}
|
||||
expandedWorkspaces={expandedWorkspaces}
|
||||
selectedSessionId={selectedSessionId}
|
||||
editingWorkspacePath={editingWorkspacePath}
|
||||
editingWorkspaceName={editingWorkspaceName}
|
||||
isSavingWorkspaceName={isSavingWorkspaceName}
|
||||
editingSessionId={editingSessionId}
|
||||
editingSessionName={editingSessionName}
|
||||
isSavingSessionName={isSavingSessionName}
|
||||
onEditingWorkspaceNameChange={setEditingWorkspaceName}
|
||||
onEditingSessionNameChange={setEditingSessionName}
|
||||
onToggleWorkspace={toggleWorkspace}
|
||||
onToggleWorkspaceStar={toggleWorkspaceStar}
|
||||
onStartWorkspaceRename={startWorkspaceRename}
|
||||
onCancelWorkspaceRename={cancelWorkspaceRename}
|
||||
onSaveWorkspaceRename={saveWorkspaceRename}
|
||||
onStartSessionRename={startSessionRename}
|
||||
onCancelSessionRename={cancelSessionRename}
|
||||
onSaveSessionRename={saveSessionRename}
|
||||
onDeleteWorkspace={requestWorkspaceDelete}
|
||||
onSessionSelect={openSession}
|
||||
onSessionDelete={handleSessionDeleteRequest}
|
||||
onNewSession={openNewSession}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
@@ -54,7 +135,7 @@ export function Sidebar() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 w-8 rounded-lg p-0 text-muted-foreground hover:bg-accent/80 hover:text-foreground"
|
||||
className="h-9 w-9 rounded-lg p-0 text-muted-foreground hover:bg-accent/80 hover:text-foreground"
|
||||
onClick={() => setCollapsed(false)}
|
||||
title="Show Sidebar"
|
||||
>
|
||||
@@ -71,6 +152,15 @@ export function Sidebar() {
|
||||
onProjectCreated={refreshWorkspaces}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SidebarDeleteModals
|
||||
workspaceDeleteTarget={workspaceDeleteTarget}
|
||||
sessionDeleteTarget={sessionDeleteTarget}
|
||||
onCancelWorkspaceDelete={cancelWorkspaceDelete}
|
||||
onConfirmWorkspaceDelete={confirmWorkspaceDelete}
|
||||
onCancelSessionDelete={cancelSessionDelete}
|
||||
onConfirmSessionDelete={confirmSessionDelete}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
133
src/components/refactored/sidebar/view/SidebarDeleteModals.tsx
Normal file
133
src/components/refactored/sidebar/view/SidebarDeleteModals.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import ReactDOM from 'react-dom';
|
||||
import { AlertTriangle, Trash2 } from 'lucide-react';
|
||||
|
||||
import type {
|
||||
SessionDeleteTarget,
|
||||
WorkspaceDeleteTarget,
|
||||
} from '@/components/refactored/sidebar/types';
|
||||
import { Button } from '@/shared/view/ui';
|
||||
|
||||
type SidebarDeleteModalsProps = {
|
||||
workspaceDeleteTarget: WorkspaceDeleteTarget | null;
|
||||
sessionDeleteTarget: SessionDeleteTarget | null;
|
||||
onCancelWorkspaceDelete: () => void;
|
||||
onConfirmWorkspaceDelete: () => void;
|
||||
onCancelSessionDelete: () => void;
|
||||
onConfirmSessionDelete: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Component layer (The Face)
|
||||
* Renders deletion confirmations with explicit file-removal messaging.
|
||||
*/
|
||||
export function SidebarDeleteModals({
|
||||
workspaceDeleteTarget,
|
||||
sessionDeleteTarget,
|
||||
onCancelWorkspaceDelete,
|
||||
onConfirmWorkspaceDelete,
|
||||
onCancelSessionDelete,
|
||||
onConfirmSessionDelete,
|
||||
}: SidebarDeleteModalsProps) {
|
||||
return (
|
||||
<>
|
||||
{workspaceDeleteTarget &&
|
||||
ReactDOM.createPortal(
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md overflow-hidden rounded-xl border border-border bg-card shadow-2xl">
|
||||
<div className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30">
|
||||
<AlertTriangle className="h-6 w-6 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="mb-2 text-lg font-semibold text-foreground">Delete workspace</h3>
|
||||
<p className="mb-2 text-sm text-muted-foreground">
|
||||
Delete{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{workspaceDeleteTarget.workspaceName}
|
||||
</span>
|
||||
?
|
||||
</p>
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-800 dark:bg-red-900/20">
|
||||
<p className="text-sm font-medium text-red-700 dark:text-red-300">
|
||||
{workspaceDeleteTarget.sessionCount} session
|
||||
{workspaceDeleteTarget.sessionCount === 1 ? '' : 's'} and all associated
|
||||
JSONL files will be deleted.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
|
||||
The workspace folder itself will stay on your system.
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 border-t border-border bg-muted/30 p-4">
|
||||
<Button variant="outline" className="flex-1" onClick={onCancelWorkspaceDelete}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="flex-1 bg-red-600 text-white hover:bg-red-700"
|
||||
onClick={onConfirmWorkspaceDelete}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
|
||||
{sessionDeleteTarget &&
|
||||
ReactDOM.createPortal(
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4 backdrop-blur-sm">
|
||||
<div className="w-full max-w-md overflow-hidden rounded-xl border border-border bg-card shadow-2xl">
|
||||
<div className="p-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30">
|
||||
<AlertTriangle className="h-6 w-6 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="mb-2 text-lg font-semibold text-foreground">Delete session</h3>
|
||||
<p className="mb-2 text-sm text-muted-foreground">
|
||||
Delete{' '}
|
||||
<span className="font-medium text-foreground">
|
||||
{sessionDeleteTarget.sessionName}
|
||||
</span>
|
||||
?
|
||||
</p>
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-3 dark:border-red-800 dark:bg-red-900/20">
|
||||
<p className="text-sm font-medium text-red-700 dark:text-red-300">
|
||||
The associated JSONL session file will also be deleted.
|
||||
</p>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-muted-foreground">
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-3 border-t border-border bg-muted/30 p-4">
|
||||
<Button variant="outline" className="flex-1" onClick={onCancelSessionDelete}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="flex-1 bg-red-600 text-white hover:bg-red-700"
|
||||
onClick={onConfirmSessionDelete}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { FolderPlus, Plus, RefreshCw, PanelLeftClose } from 'lucide-react';
|
||||
import type { SearchMode } from '../types';
|
||||
import { SidebarSearch } from './SidebarSearch';
|
||||
|
||||
import type { SearchMode } from '@/components/refactored/sidebar/types';
|
||||
import { SidebarSearch } from '@/components/refactored/sidebar/view/SidebarSearch';
|
||||
import { Button } from '@/shared/view/ui';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { IS_PLATFORM } from '@/constants/config';
|
||||
@@ -12,6 +12,10 @@ type SidebarHeaderProps = {
|
||||
isRefreshing: boolean;
|
||||
onRefresh: () => void;
|
||||
onNewProject: () => void;
|
||||
searchMode: SearchMode;
|
||||
onSearchModeChange: (mode: SearchMode) => void;
|
||||
searchFilter: string;
|
||||
onSearchFilterChange: (value: string) => void;
|
||||
};
|
||||
|
||||
export default function SidebarHeader({
|
||||
@@ -19,12 +23,12 @@ export default function SidebarHeader({
|
||||
onToggleCollapse,
|
||||
isRefreshing,
|
||||
onRefresh,
|
||||
onNewProject
|
||||
onNewProject,
|
||||
searchMode,
|
||||
onSearchModeChange,
|
||||
searchFilter,
|
||||
onSearchFilterChange,
|
||||
}: SidebarHeaderProps) {
|
||||
// UI States for search
|
||||
const [searchMode, setSearchMode] = useState<SearchMode>('projects');
|
||||
const [searchFilter, setSearchFilter] = useState('');
|
||||
|
||||
const LogoBlock = () => (
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
<div className="flex h-7 w-7 flex-shrink-0 items-center justify-center rounded-lg bg-primary/90 shadow-sm">
|
||||
@@ -63,7 +67,7 @@ export default function SidebarHeader({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 rounded-lg p-0 text-muted-foreground hover:bg-accent/80 hover:text-foreground"
|
||||
className="h-8 w-8 rounded-lg p-0 text-muted-foreground hover:bg-accent/80 hover:text-foreground"
|
||||
onClick={onRefresh}
|
||||
disabled={isRefreshing}
|
||||
title="Refresh"
|
||||
@@ -73,7 +77,7 @@ export default function SidebarHeader({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 rounded-lg p-0 text-muted-foreground hover:bg-accent/80 hover:text-foreground"
|
||||
className="h-8 w-8 rounded-lg p-0 text-muted-foreground hover:bg-accent/80 hover:text-foreground"
|
||||
onClick={onNewProject}
|
||||
title="New Project"
|
||||
>
|
||||
@@ -82,7 +86,7 @@ export default function SidebarHeader({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 rounded-lg p-0 text-muted-foreground hover:bg-accent/80 hover:text-foreground"
|
||||
className="h-8 w-8 rounded-lg p-0 text-muted-foreground hover:bg-accent/80 hover:text-foreground"
|
||||
onClick={onToggleCollapse}
|
||||
title="Hide Sidebar"
|
||||
>
|
||||
@@ -93,9 +97,9 @@ export default function SidebarHeader({
|
||||
|
||||
<SidebarSearch
|
||||
searchMode={searchMode}
|
||||
onSearchModeChange={setSearchMode}
|
||||
onSearchModeChange={onSearchModeChange}
|
||||
searchFilter={searchFilter}
|
||||
onSearchFilterChange={setSearchFilter}
|
||||
onSearchFilterChange={onSearchFilterChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -108,14 +112,14 @@ export default function SidebarHeader({
|
||||
<LogoWithLink />
|
||||
<div className="flex flex-shrink-0 gap-1.5">
|
||||
<button
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg bg-muted/50 transition-all active:scale-95 disabled:opacity-70"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg bg-muted/50 transition-all active:scale-95 disabled:opacity-70"
|
||||
onClick={onRefresh}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4 text-muted-foreground transition-opacity", isRefreshing && "animate-spin opacity-50")} />
|
||||
</button>
|
||||
<button
|
||||
className="flex h-8 w-8 items-center justify-center rounded-lg bg-primary/90 text-primary-foreground transition-all active:scale-95"
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg bg-primary/90 text-primary-foreground transition-all active:scale-95"
|
||||
onClick={onNewProject}
|
||||
>
|
||||
<FolderPlus className="h-4 w-4" />
|
||||
@@ -125,9 +129,9 @@ export default function SidebarHeader({
|
||||
|
||||
<SidebarSearch
|
||||
searchMode={searchMode}
|
||||
onSearchModeChange={setSearchMode}
|
||||
onSearchModeChange={onSearchModeChange}
|
||||
searchFilter={searchFilter}
|
||||
onSearchFilterChange={setSearchFilter}
|
||||
onSearchFilterChange={onSearchFilterChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -135,4 +139,4 @@ export default function SidebarHeader({
|
||||
<div className="nav-divider md:hidden" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Folder, MessageSquare, Search, X } from 'lucide-react';
|
||||
import { Input } from '@/shared/view/ui';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { SearchMode } from '@/components/refactored/sidebar/types/index.js';
|
||||
import type { SearchMode } from '@/components/refactored/sidebar/types';
|
||||
|
||||
|
||||
type SidebarSearchProps = {
|
||||
|
||||
196
src/components/refactored/sidebar/view/SidebarSessionItem.tsx
Normal file
196
src/components/refactored/sidebar/view/SidebarSessionItem.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { Check, Edit2, Trash2, X } from 'lucide-react';
|
||||
|
||||
import SessionProviderLogo from '@/components/llm-logo-provider/SessionProviderLogo';
|
||||
import type { WorkspaceSession } from '@/components/refactored/sidebar/types';
|
||||
import {
|
||||
formatRelativeTime,
|
||||
getSessionDisplayName,
|
||||
isRecentActivity,
|
||||
} from '@/components/refactored/sidebar/utils/workspaceTransforms';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/shared/view/ui';
|
||||
|
||||
type SidebarSessionItemProps = {
|
||||
session: WorkspaceSession;
|
||||
isSelected: boolean;
|
||||
isEditing: boolean;
|
||||
editingSessionName: string;
|
||||
isSavingSessionName: boolean;
|
||||
onEditingSessionNameChange: (name: string) => void;
|
||||
onStartEdit: () => void;
|
||||
onCancelEdit: () => void;
|
||||
onSaveEdit: () => void;
|
||||
onSelect: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
export function SidebarSessionItem({
|
||||
session,
|
||||
isSelected,
|
||||
isEditing,
|
||||
editingSessionName,
|
||||
isSavingSessionName,
|
||||
onEditingSessionNameChange,
|
||||
onStartEdit,
|
||||
onCancelEdit,
|
||||
onSaveEdit,
|
||||
onSelect,
|
||||
onDelete,
|
||||
}: SidebarSessionItemProps) {
|
||||
const sessionName = getSessionDisplayName(session);
|
||||
const sessionActivityLabel = formatRelativeTime(session.lastActivity);
|
||||
const showRecentBadge = isRecentActivity(session.lastActivity);
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
if (!isSavingSessionName) {
|
||||
onSaveEdit();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="group relative">
|
||||
<div className="md:hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'mx-3 my-0.5 rounded-md border bg-card p-2 transition-all duration-150 active:scale-[0.98]',
|
||||
isSelected ? 'border-primary/20 bg-primary/5' : 'border-border/30',
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-5 w-5 flex-shrink-0 items-center justify-center rounded-md',
|
||||
isSelected ? 'bg-primary/10' : 'bg-muted/50',
|
||||
)}
|
||||
>
|
||||
<SessionProviderLogo provider={session.provider} className="h-3 w-3" />
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">
|
||||
{sessionName}
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center gap-1">
|
||||
{showRecentBadge && <span className="h-2 w-2 rounded-full bg-green-500" />}
|
||||
<span className="text-xs text-muted-foreground">{sessionActivityLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="ml-1 flex h-7 w-7 items-center justify-center rounded-md bg-red-50 opacity-70 transition-transform active:scale-95 dark:bg-red-900/20"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-2.5 w-2.5 text-red-600 dark:text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="hidden md:block">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'h-auto w-full justify-start p-2 text-left font-normal transition-colors duration-200 hover:bg-accent/50',
|
||||
isSelected && 'bg-accent text-accent-foreground',
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!isEditing) {
|
||||
onSelect();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full min-w-0 items-start gap-2">
|
||||
<SessionProviderLogo provider={session.provider} className="mt-0.5 h-3 w-3 flex-shrink-0" />
|
||||
<div className="min-w-0 flex-1">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editingSessionName}
|
||||
onChange={(event) => onEditingSessionNameChange(event.target.value)}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => {
|
||||
event.stopPropagation();
|
||||
if (event.key === 'Enter') {
|
||||
handleSaveEdit();
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
onCancelEdit();
|
||||
}
|
||||
}}
|
||||
className="w-full rounded border border-border bg-background px-2 py-1 text-xs text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="min-w-0 flex-1 truncate text-xs font-medium text-foreground">
|
||||
{sessionName}
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 items-center gap-1 transition-opacity group-hover:opacity-0">
|
||||
{showRecentBadge && <span className="h-2 w-2 rounded-full bg-green-500" />}
|
||||
<span className="text-xs text-muted-foreground">{sessionActivityLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{isEditing ? (
|
||||
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 transform items-center gap-1">
|
||||
<button
|
||||
className="flex h-8 w-8 items-center justify-center rounded bg-green-50 hover:bg-green-100 dark:bg-green-900/20 dark:hover:bg-green-900/40"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleSaveEdit();
|
||||
}}
|
||||
title="Save"
|
||||
>
|
||||
<Check className="h-3 w-3 text-green-600 dark:text-green-400" />
|
||||
</button>
|
||||
<button
|
||||
className="flex h-8 w-8 items-center justify-center rounded bg-gray-50 hover:bg-gray-100 dark:bg-gray-900/20 dark:hover:bg-gray-900/40"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onCancelEdit();
|
||||
}}
|
||||
title="Cancel"
|
||||
>
|
||||
<X className="h-3 w-3 text-gray-600 dark:text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 transform items-center gap-1 opacity-0 transition-all duration-200 group-hover:opacity-100">
|
||||
<button
|
||||
className="flex h-8 w-8 items-center justify-center rounded bg-gray-50 hover:bg-gray-100 dark:bg-gray-900/20 dark:hover:bg-gray-900/40"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onStartEdit();
|
||||
}}
|
||||
title="Rename session"
|
||||
>
|
||||
<Edit2 className="h-3 w-3 text-gray-600 dark:text-gray-400" />
|
||||
</button>
|
||||
<button
|
||||
className="flex h-8 w-8 items-center justify-center rounded bg-red-50 hover:bg-red-100 dark:bg-red-900/20 dark:hover:bg-red-900/40"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
title="Delete session"
|
||||
>
|
||||
<Trash2 className="h-3 w-3 text-red-600 dark:text-red-400" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
420
src/components/refactored/sidebar/view/SidebarWorkspaceItem.tsx
Normal file
420
src/components/refactored/sidebar/view/SidebarWorkspaceItem.tsx
Normal file
@@ -0,0 +1,420 @@
|
||||
import {
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Edit3,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Plus,
|
||||
Star,
|
||||
Trash2,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { SidebarSessionItem } from '@/components/refactored/sidebar/view/SidebarSessionItem';
|
||||
import type { WorkspaceRecord } from '@/components/refactored/sidebar/types';
|
||||
import { getWorkspaceDisplayName } from '@/components/refactored/sidebar/utils/workspaceTransforms';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/shared/view/ui';
|
||||
|
||||
type SidebarWorkspaceItemProps = {
|
||||
workspace: WorkspaceRecord;
|
||||
isExpanded: boolean;
|
||||
selectedSessionId: string | null;
|
||||
editingWorkspacePath: string | null;
|
||||
editingWorkspaceName: string;
|
||||
isSavingWorkspaceName: boolean;
|
||||
editingSessionId: string | null;
|
||||
editingSessionName: string;
|
||||
isSavingSessionName: boolean;
|
||||
onEditingWorkspaceNameChange: (name: string) => void;
|
||||
onEditingSessionNameChange: (name: string) => void;
|
||||
onToggleWorkspace: (workspacePath: string) => void;
|
||||
onToggleWorkspaceStar: (workspacePath: string) => void;
|
||||
onStartWorkspaceRename: (workspace: WorkspaceRecord) => void;
|
||||
onCancelWorkspaceRename: () => void;
|
||||
onSaveWorkspaceRename: () => void;
|
||||
onStartSessionRename: (session: WorkspaceRecord['sessions'][number]) => void;
|
||||
onCancelSessionRename: () => void;
|
||||
onSaveSessionRename: () => void;
|
||||
onDeleteWorkspace: (workspace: WorkspaceRecord) => void;
|
||||
onSessionSelect: (workspacePath: string, sessionId: string) => void;
|
||||
onSessionDelete: (workspacePath: string, sessionId: string) => void;
|
||||
onNewSession: () => void;
|
||||
};
|
||||
|
||||
export function SidebarWorkspaceItem({
|
||||
workspace,
|
||||
isExpanded,
|
||||
selectedSessionId,
|
||||
editingWorkspacePath,
|
||||
editingWorkspaceName,
|
||||
isSavingWorkspaceName,
|
||||
editingSessionId,
|
||||
editingSessionName,
|
||||
isSavingSessionName,
|
||||
onEditingWorkspaceNameChange,
|
||||
onEditingSessionNameChange,
|
||||
onToggleWorkspace,
|
||||
onToggleWorkspaceStar,
|
||||
onStartWorkspaceRename,
|
||||
onCancelWorkspaceRename,
|
||||
onSaveWorkspaceRename,
|
||||
onStartSessionRename,
|
||||
onCancelSessionRename,
|
||||
onSaveSessionRename,
|
||||
onDeleteWorkspace,
|
||||
onSessionSelect,
|
||||
onSessionDelete,
|
||||
onNewSession,
|
||||
}: SidebarWorkspaceItemProps) {
|
||||
const isEditing = editingWorkspacePath === workspace.workspaceOriginalPath;
|
||||
const hasSelectedSession = workspace.sessions.some(
|
||||
(session) => session.sessionId === selectedSessionId,
|
||||
);
|
||||
const workspaceName = getWorkspaceDisplayName(workspace);
|
||||
const sessionCountLabel = `${workspace.sessions.length} session${
|
||||
workspace.sessions.length === 1 ? '' : 's'
|
||||
}`;
|
||||
|
||||
const handleSaveRename = () => {
|
||||
if (!isSavingWorkspaceName) {
|
||||
onSaveWorkspaceRename();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="md:space-y-1">
|
||||
<div className="group md:group">
|
||||
<div className="md:hidden">
|
||||
<div
|
||||
className={cn(
|
||||
'mx-3 my-1 rounded-lg border bg-card p-3 transition-all duration-150 active:scale-[0.98]',
|
||||
hasSelectedSession && 'border-primary/20 bg-primary/5',
|
||||
workspace.isStarred &&
|
||||
!hasSelectedSession &&
|
||||
'border-yellow-200/30 bg-yellow-50/50 dark:border-yellow-800/30 dark:bg-yellow-900/5',
|
||||
)}
|
||||
onClick={() => onToggleWorkspace(workspace.workspaceOriginalPath)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-8 w-8 items-center justify-center rounded-lg transition-colors',
|
||||
isExpanded ? 'bg-primary/10' : 'bg-muted',
|
||||
)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<FolderOpen className="h-4 w-4 text-primary" />
|
||||
) : (
|
||||
<Folder className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="text"
|
||||
value={editingWorkspaceName}
|
||||
onChange={(event) => onEditingWorkspaceNameChange(event.target.value)}
|
||||
className="w-full rounded-lg border-2 border-primary/40 bg-background px-3 py-2 text-sm text-foreground shadow-sm transition-all duration-200 focus:border-primary focus:shadow-md focus:outline-none"
|
||||
placeholder="Workspace name"
|
||||
autoFocus
|
||||
autoComplete="off"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleSaveRename();
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
onCancelWorkspaceRename();
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
fontSize: '16px',
|
||||
WebkitAppearance: 'none',
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<h3 className="truncate text-sm font-medium text-foreground">{workspaceName}</h3>
|
||||
<p className="text-xs text-muted-foreground">{sessionCountLabel}</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<button
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg bg-green-500 shadow-sm transition-all duration-150 active:scale-90 active:shadow-none dark:bg-green-600"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleSaveRename();
|
||||
}}
|
||||
>
|
||||
<Check className="h-4 w-4 text-white" />
|
||||
</button>
|
||||
<button
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg bg-gray-500 shadow-sm transition-all duration-150 active:scale-90 active:shadow-none dark:bg-gray-600"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onCancelWorkspaceRename();
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4 text-white" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className={cn(
|
||||
'flex h-9 w-9 items-center justify-center rounded-lg border transition-all duration-150 active:scale-90',
|
||||
workspace.isStarred
|
||||
? 'border-yellow-200 bg-yellow-500/10 dark:border-yellow-800 dark:bg-yellow-900/30'
|
||||
: 'border-gray-200 bg-gray-500/10 dark:border-gray-800 dark:bg-gray-900/30',
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleWorkspaceStar(workspace.workspaceOriginalPath);
|
||||
}}
|
||||
title={workspace.isStarred ? 'Remove from Starred' : 'Add to Starred'}
|
||||
>
|
||||
<Star
|
||||
className={cn(
|
||||
'h-4 w-4 transition-colors',
|
||||
workspace.isStarred
|
||||
? 'fill-current text-yellow-600 dark:text-yellow-400'
|
||||
: 'text-gray-600 dark:text-gray-400',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg border border-red-200 bg-red-500/10 active:scale-90 dark:border-red-800 dark:bg-red-900/30"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDeleteWorkspace(workspace);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-red-600 dark:text-red-400" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="flex h-9 w-9 items-center justify-center rounded-lg border border-primary/20 bg-primary/10 active:scale-90 dark:border-primary/30 dark:bg-primary/20"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onStartWorkspaceRename(workspace);
|
||||
}}
|
||||
>
|
||||
<Edit3 className="h-4 w-4 text-primary" />
|
||||
</button>
|
||||
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-muted/30">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-3 w-3 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-3 w-3 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={cn(
|
||||
'hidden h-auto w-full justify-between p-2 font-normal hover:bg-accent/50 md:flex',
|
||||
hasSelectedSession && 'bg-accent text-accent-foreground',
|
||||
workspace.isStarred &&
|
||||
!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)}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-3">
|
||||
{isExpanded ? (
|
||||
<FolderOpen className="h-4 w-4 flex-shrink-0 text-primary" />
|
||||
) : (
|
||||
<Folder className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1 text-left">
|
||||
{isEditing ? (
|
||||
<div className="space-y-1">
|
||||
<input
|
||||
type="text"
|
||||
value={editingWorkspaceName}
|
||||
onChange={(event) => onEditingWorkspaceNameChange(event.target.value)}
|
||||
className="w-full rounded border border-border bg-background px-2 py-1 text-sm text-foreground focus:ring-2 focus:ring-primary/20"
|
||||
placeholder="Workspace name"
|
||||
autoFocus
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleSaveRename();
|
||||
}
|
||||
if (event.key === 'Escape') {
|
||||
onCancelWorkspaceRename();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="truncate text-xs text-muted-foreground" title={workspace.workspaceOriginalPath}>
|
||||
{workspace.workspaceOriginalPath}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="truncate text-sm font-semibold text-foreground" title={workspaceName}>
|
||||
{workspaceName}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{workspace.sessions.length}
|
||||
<span className="ml-1 opacity-60" title={workspace.workspaceOriginalPath}>
|
||||
{' - '}
|
||||
{workspace.workspaceOriginalPath.length > 25
|
||||
? `...${workspace.workspaceOriginalPath.slice(-22)}`
|
||||
: workspace.workspaceOriginalPath}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-shrink-0 items-center gap-1">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<div
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded text-green-600 transition-colors hover:bg-green-50 hover:text-green-700 dark:hover:bg-green-900/20"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
handleSaveRename();
|
||||
}}
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
</div>
|
||||
<div
|
||||
className="flex h-8 w-8 cursor-pointer items-center justify-center rounded text-gray-500 transition-colors hover:bg-gray-50 hover:text-gray-700 dark:hover:bg-gray-800"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onCancelWorkspaceRename();
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
'touch:opacity-100 flex h-8 w-8 cursor-pointer items-center justify-center rounded opacity-0 transition-all duration-200 group-hover:opacity-100',
|
||||
workspace.isStarred ? 'opacity-100 hover:bg-yellow-50 dark:hover:bg-yellow-900/20' : 'hover:bg-accent',
|
||||
)}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleWorkspaceStar(workspace.workspaceOriginalPath);
|
||||
}}
|
||||
title={workspace.isStarred ? 'Remove from Starred' : 'Add to Starred'}
|
||||
>
|
||||
<Star
|
||||
className={cn(
|
||||
'h-3 w-3 transition-colors',
|
||||
workspace.isStarred
|
||||
? 'fill-current text-yellow-600 dark:text-yellow-400'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="touch:opacity-100 flex h-8 w-8 cursor-pointer items-center justify-center rounded opacity-0 transition-all duration-200 hover:bg-accent group-hover:opacity-100"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onStartWorkspaceRename(workspace);
|
||||
}}
|
||||
title="Rename workspace"
|
||||
>
|
||||
<Edit3 className="h-3 w-3" />
|
||||
</div>
|
||||
<div
|
||||
className="touch:opacity-100 flex h-8 w-8 cursor-pointer items-center justify-center rounded opacity-0 transition-all duration-200 hover:bg-red-50 group-hover:opacity-100 dark:hover:bg-red-900/20"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onDeleteWorkspace(workspace);
|
||||
}}
|
||||
title="Delete workspace"
|
||||
>
|
||||
<Trash2 className="h-3 w-3 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground transition-colors group-hover:text-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground transition-colors group-hover:text-foreground" />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="ml-3 space-y-1 border-l border-border pl-3">
|
||||
{workspace.sessions.length === 0 ? (
|
||||
<div className="px-3 py-2 text-left">
|
||||
<p className="text-xs text-muted-foreground">No sessions yet</p>
|
||||
</div>
|
||||
) : (
|
||||
workspace.sessions.map((session) => (
|
||||
<SidebarSessionItem
|
||||
key={session.sessionId}
|
||||
session={session}
|
||||
isSelected={session.sessionId === selectedSessionId}
|
||||
isEditing={editingSessionId === session.sessionId}
|
||||
editingSessionName={editingSessionName}
|
||||
isSavingSessionName={isSavingSessionName}
|
||||
onEditingSessionNameChange={onEditingSessionNameChange}
|
||||
onStartEdit={() => onStartSessionRename(session)}
|
||||
onCancelEdit={onCancelSessionRename}
|
||||
onSaveEdit={onSaveSessionRename}
|
||||
onSelect={() =>
|
||||
onSessionSelect(workspace.workspaceOriginalPath, session.sessionId)
|
||||
}
|
||||
onDelete={() =>
|
||||
onSessionDelete(workspace.workspaceOriginalPath, session.sessionId)
|
||||
}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
<div className="px-3 pb-2 md:hidden">
|
||||
<button
|
||||
className="flex h-8 w-full items-center justify-center gap-2 rounded-md bg-primary text-xs font-medium text-primary-foreground transition-all duration-150 hover:bg-primary/90 active:scale-[0.98]"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onNewSession();
|
||||
}}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
New Session
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="mt-1 hidden h-8 w-full justify-start gap-2 bg-primary text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90 md:flex"
|
||||
onClick={onNewSession}
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
New Session
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
169
src/components/refactored/sidebar/view/SidebarWorkspaceList.tsx
Normal file
169
src/components/refactored/sidebar/view/SidebarWorkspaceList.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import type { WorkspaceRecord } from '@/components/refactored/sidebar/types';
|
||||
import { SidebarWorkspaceItem } from '@/components/refactored/sidebar/view/SidebarWorkspaceItem';
|
||||
|
||||
type SidebarWorkspaceListProps = {
|
||||
workspacesCount: number;
|
||||
searchFilter: string;
|
||||
starredWorkspaces: WorkspaceRecord[];
|
||||
unstarredWorkspaces: WorkspaceRecord[];
|
||||
expandedWorkspaces: Set<string>;
|
||||
selectedSessionId: string | null;
|
||||
editingWorkspacePath: string | null;
|
||||
editingWorkspaceName: string;
|
||||
isSavingWorkspaceName: boolean;
|
||||
editingSessionId: string | null;
|
||||
editingSessionName: string;
|
||||
isSavingSessionName: boolean;
|
||||
onEditingWorkspaceNameChange: (name: string) => void;
|
||||
onEditingSessionNameChange: (name: string) => void;
|
||||
onToggleWorkspace: (workspacePath: string) => void;
|
||||
onToggleWorkspaceStar: (workspacePath: string) => void;
|
||||
onStartWorkspaceRename: (workspace: WorkspaceRecord) => void;
|
||||
onCancelWorkspaceRename: () => void;
|
||||
onSaveWorkspaceRename: () => void;
|
||||
onStartSessionRename: (session: WorkspaceRecord['sessions'][number]) => void;
|
||||
onCancelSessionRename: () => void;
|
||||
onSaveSessionRename: () => void;
|
||||
onDeleteWorkspace: (workspace: WorkspaceRecord) => void;
|
||||
onSessionSelect: (workspacePath: string, sessionId: string) => void;
|
||||
onSessionDelete: (workspacePath: string, sessionId: string) => void;
|
||||
onNewSession: () => void;
|
||||
};
|
||||
|
||||
const SectionHeading = ({ title }: { title: string }) => (
|
||||
<div className="px-3 pb-1 pt-2">
|
||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">{title}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
const EmptyState = ({ title, description }: { title: string; description: string }) => (
|
||||
<div className="px-4 py-8 text-center">
|
||||
<h3 className="mb-2 text-sm font-medium text-foreground">{title}</h3>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Component layer (The Face)
|
||||
* Displays Starred and regular workspace sections with shared item rendering.
|
||||
*/
|
||||
export function SidebarWorkspaceList({
|
||||
workspacesCount,
|
||||
searchFilter,
|
||||
starredWorkspaces,
|
||||
unstarredWorkspaces,
|
||||
expandedWorkspaces,
|
||||
selectedSessionId,
|
||||
editingWorkspacePath,
|
||||
editingWorkspaceName,
|
||||
isSavingWorkspaceName,
|
||||
editingSessionId,
|
||||
editingSessionName,
|
||||
isSavingSessionName,
|
||||
onEditingWorkspaceNameChange,
|
||||
onEditingSessionNameChange,
|
||||
onToggleWorkspace,
|
||||
onToggleWorkspaceStar,
|
||||
onStartWorkspaceRename,
|
||||
onCancelWorkspaceRename,
|
||||
onSaveWorkspaceRename,
|
||||
onStartSessionRename,
|
||||
onCancelSessionRename,
|
||||
onSaveSessionRename,
|
||||
onDeleteWorkspace,
|
||||
onSessionSelect,
|
||||
onSessionDelete,
|
||||
onNewSession,
|
||||
}: SidebarWorkspaceListProps) {
|
||||
const visibleWorkspaceCount = starredWorkspaces.length + unstarredWorkspaces.length;
|
||||
|
||||
if (workspacesCount === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="No workspaces yet"
|
||||
description="Create a project to start adding sessions."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (visibleWorkspaceCount === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="No matches found"
|
||||
description={`No results for "${searchFilter}".`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pb-safe-area-inset-bottom md:space-y-1">
|
||||
{starredWorkspaces.length > 0 && (
|
||||
<>
|
||||
<SectionHeading title="Starred" />
|
||||
{starredWorkspaces.map((workspace) => (
|
||||
<SidebarWorkspaceItem
|
||||
key={workspace.workspaceOriginalPath}
|
||||
workspace={workspace}
|
||||
isExpanded={expandedWorkspaces.has(workspace.workspaceOriginalPath)}
|
||||
selectedSessionId={selectedSessionId}
|
||||
editingWorkspacePath={editingWorkspacePath}
|
||||
editingWorkspaceName={editingWorkspaceName}
|
||||
isSavingWorkspaceName={isSavingWorkspaceName}
|
||||
editingSessionId={editingSessionId}
|
||||
editingSessionName={editingSessionName}
|
||||
isSavingSessionName={isSavingSessionName}
|
||||
onEditingWorkspaceNameChange={onEditingWorkspaceNameChange}
|
||||
onEditingSessionNameChange={onEditingSessionNameChange}
|
||||
onToggleWorkspace={onToggleWorkspace}
|
||||
onToggleWorkspaceStar={onToggleWorkspaceStar}
|
||||
onStartWorkspaceRename={onStartWorkspaceRename}
|
||||
onCancelWorkspaceRename={onCancelWorkspaceRename}
|
||||
onSaveWorkspaceRename={onSaveWorkspaceRename}
|
||||
onStartSessionRename={onStartSessionRename}
|
||||
onCancelSessionRename={onCancelSessionRename}
|
||||
onSaveSessionRename={onSaveSessionRename}
|
||||
onDeleteWorkspace={onDeleteWorkspace}
|
||||
onSessionSelect={onSessionSelect}
|
||||
onSessionDelete={onSessionDelete}
|
||||
onNewSession={onNewSession}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{unstarredWorkspaces.length > 0 && (
|
||||
<>
|
||||
<SectionHeading title="Projects" />
|
||||
{unstarredWorkspaces.map((workspace) => (
|
||||
<SidebarWorkspaceItem
|
||||
key={workspace.workspaceOriginalPath}
|
||||
workspace={workspace}
|
||||
isExpanded={expandedWorkspaces.has(workspace.workspaceOriginalPath)}
|
||||
selectedSessionId={selectedSessionId}
|
||||
editingWorkspacePath={editingWorkspacePath}
|
||||
editingWorkspaceName={editingWorkspaceName}
|
||||
isSavingWorkspaceName={isSavingWorkspaceName}
|
||||
editingSessionId={editingSessionId}
|
||||
editingSessionName={editingSessionName}
|
||||
isSavingSessionName={isSavingSessionName}
|
||||
onEditingWorkspaceNameChange={onEditingWorkspaceNameChange}
|
||||
onEditingSessionNameChange={onEditingSessionNameChange}
|
||||
onToggleWorkspace={onToggleWorkspace}
|
||||
onToggleWorkspaceStar={onToggleWorkspaceStar}
|
||||
onStartWorkspaceRename={onStartWorkspaceRename}
|
||||
onCancelWorkspaceRename={onCancelWorkspaceRename}
|
||||
onSaveWorkspaceRename={onSaveWorkspaceRename}
|
||||
onStartSessionRename={onStartSessionRename}
|
||||
onCancelSessionRename={onCancelSessionRename}
|
||||
onSaveSessionRename={onSaveSessionRename}
|
||||
onDeleteWorkspace={onDeleteWorkspace}
|
||||
onSessionSelect={onSessionSelect}
|
||||
onSessionDelete={onSessionDelete}
|
||||
onNewSession={onNewSession}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user