Merge pull request #56 from TeigenZhang/feat/case-manage-reorder-delete

feat: add case reorder and delete in Manage tab
This commit is contained in:
Ark0N
2026-04-03 03:53:25 +02:00
committed by GitHub
7 changed files with 341 additions and 5 deletions
+6
View File
@@ -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',
};
// ═══════════════════════════════════════════════════════════════
+8
View File
@@ -1360,6 +1360,7 @@
<div class="modal-tabs">
<button class="modal-tab-btn active" data-tab="case-create">Create New</button>
<button class="modal-tab-btn" data-tab="case-link">Link Existing</button>
<button class="modal-tab-btn" data-tab="case-manage">Manage</button>
</div>
<div class="modal-body">
<!-- Create New Tab -->
@@ -1387,6 +1388,13 @@
<span class="form-hint">Absolute path to an existing project folder, e.g. /home/you/my-project</span>
</div>
</div>
<!-- Manage Tab -->
<div class="modal-tab-content hidden" id="case-manage">
<div class="case-manage-list" id="caseManageList">
<!-- Populated by JS -->
</div>
<span class="form-hint" style="margin-top: 8px; display: block;">Drag or use arrows to reorder. Changes are saved automatically.</span>
</div>
</div>
<div class="form-actions">
<button class="btn-toolbar" onclick="app.closeCreateCaseModal()">Cancel</button>
+134 -3
View File
@@ -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 = '<div class="form-hint" style="text-align: center; padding: 2rem 0;">No cases yet</div>';
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 += `
<div class="case-manage-item" data-case="${escapeHtml(c.name)}">
<div class="case-manage-info">
<span class="case-manage-name">${escapeHtml(c.name)}</span>
<span class="case-manage-path">${escapeHtml(pathDisplay)}</span>
</div>
<div class="case-manage-actions">
<button class="case-manage-btn" onclick="app.moveCaseUp('${escapeHtml(c.name)}')"
title="Move up" ${isFirst ? 'disabled' : ''}>&#x25B2;</button>
<button class="case-manage-btn" onclick="app.moveCaseDown('${escapeHtml(c.name)}')"
title="Move down" ${isLast ? 'disabled' : ''}>&#x25BC;</button>
<button class="case-manage-btn case-manage-btn-delete" onclick="app.deleteCase('${escapeHtml(c.name)}')"
title="Delete case">&#x2715;</button>
</div>
</div>
`;
});
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, {
</svg>
</span>
<span class="mobile-case-item-name">${escapeHtml(c.name)}</span>
<span class="mobile-case-item-delete" onclick="event.stopPropagation(); app.deleteCaseMobile('${escapeHtml(c.name)}')" title="Delete">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</span>
<span class="mobile-case-item-check">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="20 6 9 17 4 12"/>
@@ -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();
+106
View File
@@ -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);
+76 -2
View File
@@ -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<Record<string, unknown>>(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<ApiResponse<{ name: string }>> => {
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<ApiResponse<{ order: string[] }>> => {
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<string, unknown> = {};
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 };
+5
View File
@@ -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(),
+6
View File
@@ -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;