diff --git a/src/web/public/constants.js b/src/web/public/constants.js index ef31a045..dd954e1d 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -291,6 +291,12 @@ const SSE_EVENTS = { ORCHESTRATOR_TASK_FAILED: 'orchestrator:taskFailed', ORCHESTRATOR_COMPLETED: 'orchestrator:completed', ORCHESTRATOR_ERROR: 'orchestrator:error', + + // Cases + CASE_CREATED: 'case:created', + CASE_LINKED: 'case:linked', + CASE_DELETED: 'case:deleted', + CASE_ORDER_CHANGED: 'case:order-changed', }; // ═══════════════════════════════════════════════════════════════ diff --git a/src/web/public/index.html b/src/web/public/index.html index 250be5aa..b8599ba1 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1360,6 +1360,7 @@ + +
diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 8e893093..b63fa48b 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -1038,13 +1038,19 @@ Object.assign(CodemanApp.prototype, { modal.querySelectorAll('.modal-tab-content').forEach(content => { content.classList.toggle('hidden', content.id !== tabName); }); - // Update submit button text + // Update submit button (hide for manage tab) const submitBtn = document.getElementById('caseModalSubmit'); - submitBtn.textContent = tabName === 'case-create' ? 'Create' : 'Link'; + if (tabName === 'case-manage') { + submitBtn.style.display = 'none'; + this.renderCaseManageList(); + } else { + submitBtn.style.display = ''; + submitBtn.textContent = tabName === 'case-create' ? 'Create' : 'Link'; + } // Focus appropriate input if (tabName === 'case-create') { document.getElementById('newCaseName').focus(); - } else { + } else if (tabName === 'case-link') { document.getElementById('linkCaseName').focus(); } }, @@ -1152,6 +1158,107 @@ Object.assign(CodemanApp.prototype, { }, + // ═══════════════════════════════════════════════════════════════ + // Case Management (reorder + delete) + // ═══════════════════════════════════════════════════════════════ + + renderCaseManageList() { + const container = document.getElementById('caseManageList'); + const cases = this.cases || []; + if (cases.length === 0) { + container.innerHTML = '
No cases yet
'; + return; + } + + let html = ''; + cases.forEach((c, idx) => { + const isFirst = idx === 0; + const isLast = idx === cases.length - 1; + const pathDisplay = c.path ? c.path.replace(/^\/Users\/[^/]+/, '~') : ''; + html += ` +
+
+ ${escapeHtml(c.name)} + ${escapeHtml(pathDisplay)} +
+
+ + + +
+
+ `; + }); + container.innerHTML = html; + }, + + async moveCaseUp(name) { + const cases = this.cases || []; + const idx = cases.findIndex(c => c.name === name); + if (idx <= 0) return; + // Swap positions (immutable) + const reordered = [...cases]; + [reordered[idx - 1], reordered[idx]] = [reordered[idx], reordered[idx - 1]]; + this.cases = reordered; + this.renderCaseManageList(); + await this.saveCaseOrder(reordered.map(c => c.name)); + }, + + async moveCaseDown(name) { + const cases = this.cases || []; + const idx = cases.findIndex(c => c.name === name); + if (idx < 0 || idx >= cases.length - 1) return; + const reordered = [...cases]; + [reordered[idx], reordered[idx + 1]] = [reordered[idx + 1], reordered[idx]]; + this.cases = reordered; + this.renderCaseManageList(); + await this.saveCaseOrder(reordered.map(c => c.name)); + }, + + async deleteCase(name) { + if (!confirm(`Delete case "${name}"? Linked cases will only be unlinked (folder preserved). Created cases will be permanently deleted.`)) { + return; + } + + try { + const res = await fetch(`/api/cases/${encodeURIComponent(name)}`, { method: 'DELETE' }); + const data = await res.json(); + if (data.success) { + this.showToast(`Case "${name}" ${data.data?.type === 'unlinked' ? 'unlinked' : 'deleted'}`, 'success'); + // Remove from current list and refresh + this.cases = (this.cases || []).filter(c => c.name !== name); + this.renderCaseManageList(); + // Refresh the dropdown + const select = document.getElementById('quickStartCase'); + const currentCase = select.value; + await this.loadQuickStartCases(currentCase === name ? null : currentCase); + } else { + this.showToast(data.error || 'Failed to delete case', 'error'); + } + } catch (err) { + this.showToast('Failed to delete case: ' + err.message, 'error'); + } + }, + + async saveCaseOrder(order) { + try { + await fetch('/api/cases/order', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ order }) + }); + // Refresh dropdown to reflect new order + const select = document.getElementById('quickStartCase'); + const currentCase = select.value; + await this.loadQuickStartCases(currentCase); + } catch (err) { + this.showToast('Failed to save case order: ' + err.message, 'error'); + } + }, + // ═══════════════════════════════════════════════════════════════ // Mobile Case Picker // ═══════════════════════════════════════════════════════════════ @@ -1181,6 +1288,11 @@ Object.assign(CodemanApp.prototype, { ${escapeHtml(c.name)} + + + + + @@ -1226,6 +1338,25 @@ Object.assign(CodemanApp.prototype, { } }, + async deleteCaseMobile(name) { + if (!confirm(`Delete case "${name}"?`)) return; + try { + const res = await fetch(`/api/cases/${encodeURIComponent(name)}`, { method: 'DELETE' }); + const data = await res.json(); + if (data.success) { + this.showToast(`Case "${name}" ${data.data?.type === 'unlinked' ? 'unlinked' : 'deleted'}`, 'success'); + this.cases = (this.cases || []).filter(c => c.name !== name); + // Refresh mobile picker and dropdown + this.closeMobileCasePicker(); + await this.loadQuickStartCases(); + } else { + this.showToast(data.error || 'Failed to delete case', 'error'); + } + } catch (err) { + this.showToast('Failed to delete case: ' + err.message, 'error'); + } + }, + showCreateCaseFromMobile() { // Close mobile picker first this.closeMobileCasePicker(); diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 270868fd..7b4b9ef2 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -2964,6 +2964,93 @@ body { font-size: 0.6rem; } +/* Case Management List (Manage tab) */ +.case-manage-list { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 320px; + overflow-y: auto; +} + +.case-manage-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 10px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 6px; + transition: background var(--transition-smooth); +} + +.case-manage-item:hover { + background: rgba(255, 255, 255, 0.06); +} + +.case-manage-info { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; +} + +.case-manage-name { + font-size: 0.8rem; + font-weight: 500; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.case-manage-path { + font-size: 0.65rem; + color: var(--text-dim); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.case-manage-actions { + display: flex; + gap: 4px; + margin-left: 10px; + flex-shrink: 0; +} + +.case-manage-btn { + display: flex; + align-items: center; + justify-content: center; + width: 26px; + height: 26px; + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 4px; + color: var(--text-dim); + font-size: 0.65rem; + cursor: pointer; + transition: all var(--transition-smooth); +} + +.case-manage-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.1); + color: var(--text); +} + +.case-manage-btn:disabled { + opacity: 0.25; + cursor: not-allowed; +} + +.case-manage-btn-delete:hover:not(:disabled) { + background: rgba(239, 68, 68, 0.15); + border-color: rgba(239, 68, 68, 0.3); + color: #ef4444; +} + .toolbar-input { padding: 0.4rem 0.5rem; background: var(--bg-input); @@ -3692,6 +3779,25 @@ body { opacity: 1; } +.mobile-case-item-delete { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-dim); + opacity: 0.4; + flex-shrink: 0; + border-radius: 4px; + transition: all var(--transition-smooth); +} + +.mobile-case-item-delete:active { + opacity: 1; + color: #ef4444; + background: rgba(239, 68, 68, 0.15); +} + .mobile-case-picker-footer { padding: 12px 20px 16px; border-top: 1px solid rgba(255, 255, 255, 0.1); diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index efe0d9f8..6b825164 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -11,10 +11,10 @@ import { join, resolve } from 'node:path'; import { homedir } from 'node:os'; import type { ApiResponse, CaseInfo } from '../../types.js'; import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js'; -import { CreateCaseSchema, LinkCaseSchema } from '../schemas.js'; +import { CreateCaseSchema, LinkCaseSchema, CaseOrderSchema } from '../schemas.js'; import { generateClaudeMd } from '../../templates/claude-md.js'; import { writeHooksConfig } from '../../hooks-config.js'; -import { CASES_DIR, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.js'; +import { CASES_DIR, SETTINGS_PATH, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.js'; import { SseEvent } from '../sse-events.js'; import type { EventPort, ConfigPort } from '../ports/index.js'; @@ -71,6 +71,18 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config } } + // Sort by persisted caseOrder from settings.json + const settings = await readJsonConfig>(SETTINGS_PATH, 'settings', {}); + const caseOrder = Array.isArray(settings.caseOrder) ? (settings.caseOrder as string[]) : []; + if (caseOrder.length > 0) { + const orderMap = new Map(caseOrder.map((name, idx) => [name, idx])); + cases.sort((a, b) => { + const ai = orderMap.get(a.name) ?? Number.MAX_SAFE_INTEGER; + const bi = orderMap.get(b.name) ?? Number.MAX_SAFE_INTEGER; + return ai - bi; + }); + } + return cases; }); @@ -150,6 +162,68 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config } }); + // ========== Delete / Unlink Case ========== + + app.delete('/api/cases/:name', async (req): Promise> => { + const { name } = req.params as { name: string }; + + if (!validatePathWithinBase(name, CASES_DIR)) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name'); + } + + // Check linked cases first — unlink only, don't delete the actual directory + const linkedCases = await readLinkedCases(); + if (linkedCases[name]) { + delete linkedCases[name]; + try { + await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(linkedCases, null, 2)); + ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'unlinked' }); + return { success: true, data: { name } }; + } catch (err) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); + } + } + + // Case in CASES_DIR — delete the entire directory + const casePath = join(CASES_DIR, name); + if (!existsSync(casePath)) { + return createErrorResponse(ApiErrorCode.NOT_FOUND, `Case "${name}" not found`); + } + + try { + await fs.rm(casePath, { recursive: true, force: true }); + ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'deleted' }); + return { success: true, data: { name } }; + } catch (err) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); + } + }); + + // ========== Reorder Cases ========== + + app.put('/api/cases/order', async (req): Promise> => { + const { order } = parseBody(CaseOrderSchema, req.body, 'Invalid order data'); + + try { + const dir = join(homedir(), '.codeman'); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + let existing: Record = {}; + try { + existing = JSON.parse(await fs.readFile(SETTINGS_PATH, 'utf-8')); + } catch { + /* ignore */ + } + const merged = { ...existing, caseOrder: order }; + await fs.writeFile(SETTINGS_PATH, JSON.stringify(merged, null, 2)); + ctx.broadcast(SseEvent.CaseOrderChanged, { order }); + return { success: true, data: { order } }; + } catch (err) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); + } + }); + app.get('/api/cases/:name', async (req) => { const { name } = req.params as { name: string }; diff --git a/src/web/schemas.ts b/src/web/schemas.ts index d142e167..a5569952 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -431,6 +431,11 @@ export const LinkCaseSchema = z.object({ path: safePathSchema, }); +/** PUT /api/cases/order */ +export const CaseOrderSchema = z.object({ + order: z.array(z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format')), +}); + /** POST /api/auth/revoke */ export const RevokeSessionSchema = z.object({ sessionToken: z.string().min(1).max(200).optional(), diff --git a/src/web/sse-events.ts b/src/web/sse-events.ts index 5c93e7be..d83893fa 100644 --- a/src/web/sse-events.ts +++ b/src/web/sse-events.ts @@ -325,6 +325,10 @@ export const OrchestratorError = 'orchestrator:error' as const; export const CaseCreated = 'case:created' as const; /** Existing directory linked as a case. */ export const CaseLinked = 'case:linked' as const; +/** Case deleted or unlinked. */ +export const CaseDeleted = 'case:deleted' as const; +/** Case ordering changed. */ +export const CaseOrderChanged = 'case:order-changed' as const; // ─── Namespace Re-export ───────────────────────────────────────────────────── @@ -485,4 +489,6 @@ export const SseEvent = { // Cases CaseCreated, CaseLinked, + CaseDeleted, + CaseOrderChanged, } as const;