mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat(cases): refresh the case picker and add search to Manage (#483)
The Run bar's case picker only loaded /api/cases at page load, so folders deleted or created on disk stayed listed until a reload. It now refetches on open and every 5 seconds while open, repainting only when the list changed and falling back to another case if the selected one was removed. The Manage tab of Add Case gains a search box filtering by name or path. Reorder arrows are disabled while a filter is active so a swap cannot involve a hidden case. Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
77ba41f8da
commit
8841bcc93f
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"aicodeman": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add a search box to the Manage tab of the Add Case dialog. It filters the case list by name or path, and the reorder arrows are disabled while a filter is active so a swap cannot involve a hidden case.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"aicodeman": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
The case picker now refreshes its list from `/api/cases` when it opens and every 5 seconds while it stays open, so folders deleted or created on disk appear without a page reload. If the selected case has been removed, the picker falls back to another case without saving it as the last-used one.
|
||||||
@@ -3175,6 +3175,7 @@
|
|||||||
<h2>Manage</h2>
|
<h2>Manage</h2>
|
||||||
</div>
|
</div>
|
||||||
<p class="set-section-blurb">Reorder or remove cases, and pick up anything exported from a docker case.</p>
|
<p class="set-section-blurb">Reorder or remove cases, and pick up anything exported from a docker case.</p>
|
||||||
|
<input type="search" id="caseManageSearch" class="set-input" placeholder="Search cases by name or path" autocomplete="off" spellcheck="false" aria-label="Search cases" oninput="app.setCaseManageFilter(this.value)" style="margin-bottom: 8px;">
|
||||||
<div class="case-manage-list" id="caseManageList">
|
<div class="case-manage-list" id="caseManageList">
|
||||||
<!-- Populated by JS -->
|
<!-- Populated by JS -->
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -122,6 +122,9 @@ const RUN_MODE_LAUNCH = {
|
|||||||
* (`openSessionOptions`), guaranteed to drift from each other the moment a
|
* (`openSessionOptions`), guaranteed to drift from each other the moment a
|
||||||
* ninth CLI landed in one and not the other.
|
* ninth CLI landed in one and not the other.
|
||||||
*/
|
*/
|
||||||
|
/** How often the OPEN case picker re-reads /api/cases (it also refreshes once on open). */
|
||||||
|
const CASE_PICKER_REFRESH_MS = 5000;
|
||||||
|
|
||||||
const EXTERNAL_CLI_MODES = new Set(Object.keys(RUN_MODE_LAUNCH));
|
const EXTERNAL_CLI_MODES = new Set(Object.keys(RUN_MODE_LAUNCH));
|
||||||
const BUILT_IN_RUN_MODES = new Set(['claude', 'shell', ...Object.keys(RUN_MODE_LAUNCH)]);
|
const BUILT_IN_RUN_MODES = new Set(['claude', 'shell', ...Object.keys(RUN_MODE_LAUNCH)]);
|
||||||
|
|
||||||
@@ -246,16 +249,74 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const input = document.getElementById('quickStartCaseSearch');
|
const input = document.getElementById('quickStartCaseSearch');
|
||||||
const list = document.getElementById('quickStartCaseList');
|
const list = document.getElementById('quickStartCaseList');
|
||||||
if (!input || !list) return;
|
if (!input || !list) return;
|
||||||
|
const wasOpen = this._casePickerOpen === true;
|
||||||
this._casePickerOpen = true;
|
this._casePickerOpen = true;
|
||||||
this._casePickerFilter = filter;
|
this._casePickerFilter = filter;
|
||||||
this._casePickerActiveIndex = 0;
|
this._casePickerActiveIndex = 0;
|
||||||
input.setAttribute('aria-expanded', 'true');
|
input.setAttribute('aria-expanded', 'true');
|
||||||
this.renderCasePickerList();
|
this.renderCasePickerList();
|
||||||
|
// Every keystroke re-enters here, so only the closed -> open transition
|
||||||
|
// refreshes and arms the timer; typing must not fire a fetch per key.
|
||||||
|
if (!wasOpen) this._startCasePickerRefresh();
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Re-read the case list while the picker is open, so folders deleted or created on disk show up without a page reload. */
|
||||||
|
_startCasePickerRefresh() {
|
||||||
|
void this.refreshCasePickerCases();
|
||||||
|
if (this._casePickerRefreshTimer) return;
|
||||||
|
this._casePickerRefreshTimer = setInterval(() => void this.refreshCasePickerCases(), CASE_PICKER_REFRESH_MS);
|
||||||
|
},
|
||||||
|
|
||||||
|
_stopCasePickerRefresh() {
|
||||||
|
if (this._casePickerRefreshTimer) clearInterval(this._casePickerRefreshTimer);
|
||||||
|
this._casePickerRefreshTimer = null;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lighter than loadQuickStartCases(): that one closes the picker and re-picks a
|
||||||
|
* selection, which would yank the list away from someone mid-browse. This only
|
||||||
|
* swaps the data and repaints, and does nothing when the list is unchanged.
|
||||||
|
*/
|
||||||
|
async refreshCasePickerCases() {
|
||||||
|
if (this._casePickerRefreshInFlight) return;
|
||||||
|
this._casePickerRefreshInFlight = true;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/cases');
|
||||||
|
if (!res.ok) return;
|
||||||
|
const cases = (await res.json()).data;
|
||||||
|
if (!Array.isArray(cases) || !this._casePickerOpen) return;
|
||||||
|
const signature = list => JSON.stringify((list || []).map(c => [c.name, c.path, c.location]));
|
||||||
|
if (signature(cases) === signature(this.cases)) return;
|
||||||
|
this.cases = cases;
|
||||||
|
|
||||||
|
const select = document.getElementById('quickStartCase');
|
||||||
|
if (select) {
|
||||||
|
const previous = select.value;
|
||||||
|
this.renderQuickStartCaseSelectOptions(select, this.getCasePickerOptions());
|
||||||
|
if (cases.some(c => c.name === previous)) {
|
||||||
|
select.value = previous;
|
||||||
|
} else if (cases.length > 0) {
|
||||||
|
// The selected case was removed on disk: fall back the way the initial
|
||||||
|
// load does, without saving it as the user's last-used case.
|
||||||
|
const fallback = cases.find(c => c.name === 'testcase') || cases[0];
|
||||||
|
select.value = fallback.name;
|
||||||
|
this.updateDirDisplayForCase(fallback.name);
|
||||||
|
this.updateMobileCaseLabel(fallback.name);
|
||||||
|
this.updateCasePickerInput(fallback.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.renderCasePickerList();
|
||||||
|
} catch {
|
||||||
|
// A failed poll leaves the list as it was; the next tick retries.
|
||||||
|
} finally {
|
||||||
|
this._casePickerRefreshInFlight = false;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
closeCasePicker() {
|
closeCasePicker() {
|
||||||
const input = document.getElementById('quickStartCaseSearch');
|
const input = document.getElementById('quickStartCaseSearch');
|
||||||
const list = document.getElementById('quickStartCaseList');
|
const list = document.getElementById('quickStartCaseList');
|
||||||
|
this._stopCasePickerRefresh();
|
||||||
this._casePickerOpen = false;
|
this._casePickerOpen = false;
|
||||||
this._casePickerFilter = '';
|
this._casePickerFilter = '';
|
||||||
input?.setAttribute('aria-expanded', 'false');
|
input?.setAttribute('aria-expanded', 'false');
|
||||||
@@ -4286,6 +4347,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Case Management (reorder + delete)
|
// Case Management (reorder + delete)
|
||||||
// ═══════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
setCaseManageFilter(value) {
|
||||||
|
this._caseManageFilter = String(value || '');
|
||||||
|
this.renderCaseManageList();
|
||||||
|
},
|
||||||
|
|
||||||
renderCaseManageList() {
|
renderCaseManageList() {
|
||||||
const container = document.getElementById('caseManageList');
|
const container = document.getElementById('caseManageList');
|
||||||
const cases = this.cases || [];
|
const cases = this.cases || [];
|
||||||
@@ -4294,6 +4360,18 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Every term must appear in the name or path (same rule as the Run picker's
|
||||||
|
// filter). Reordering stays on the FULL list, so the arrows are disabled while
|
||||||
|
// a filter is active: a swap with a neighbour the user cannot see is a surprise.
|
||||||
|
const terms = (this._caseManageFilter || '').trim().toLowerCase().split(/\s+/).filter(Boolean);
|
||||||
|
const filtering = terms.length > 0;
|
||||||
|
const visible = filtering
|
||||||
|
? cases.filter(c => {
|
||||||
|
const haystack = `${c.name} ${c.path || ''}`.toLowerCase();
|
||||||
|
return terms.every(term => haystack.includes(term));
|
||||||
|
})
|
||||||
|
: cases;
|
||||||
|
|
||||||
// Cases an agent worker created (server-side marker file, see agent-case-marker.ts).
|
// Cases an agent worker created (server-side marker file, see agent-case-marker.ts).
|
||||||
// A long orchestration leaves one scratch directory per worker behind, so they get
|
// A long orchestration leaves one scratch directory per worker behind, so they get
|
||||||
// a badge and a bulk cleanup entry point rather than having to be recognised by name.
|
// a badge and a bulk cleanup entry point rather than having to be recognised by name.
|
||||||
@@ -4305,9 +4383,14 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
title="Review and delete the scratch cases agent workers left behind">Clean up</button>
|
title="Review and delete the scratch cases agent workers left behind">Clean up</button>
|
||||||
</div>`
|
</div>`
|
||||||
: '';
|
: '';
|
||||||
cases.forEach((c, idx) => {
|
if (filtering && visible.length === 0) {
|
||||||
const isFirst = idx === 0;
|
html += '<div class="form-hint" style="text-align: center; padding: 2rem 0;">No cases match</div>';
|
||||||
const isLast = idx === cases.length - 1;
|
}
|
||||||
|
visible.forEach(c => {
|
||||||
|
const idx = cases.indexOf(c);
|
||||||
|
const isFirst = filtering || idx === 0;
|
||||||
|
const isLast = filtering || idx === cases.length - 1;
|
||||||
|
const reorderTitle = filtering ? 'Clear the search to reorder' : null;
|
||||||
// Was `/Users/<user>` only, the mirror image of the Run menu's bug: every
|
// Was `/Users/<user>` only, the mirror image of the Run menu's bug: every
|
||||||
// case path on a Linux host rendered in full, unabbreviated.
|
// case path on a Linux host rendered in full, unabbreviated.
|
||||||
const pathDisplay = c.path ? this._shortenHomePath(c.path) : '';
|
const pathDisplay = c.path ? this._shortenHomePath(c.path) : '';
|
||||||
@@ -4331,9 +4414,9 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
: ''
|
: ''
|
||||||
}
|
}
|
||||||
<button class="case-manage-btn" onclick="app.moveCaseUp(${escapeHtml(JSON.stringify(c.name))})"
|
<button class="case-manage-btn" onclick="app.moveCaseUp(${escapeHtml(JSON.stringify(c.name))})"
|
||||||
title="Move up" ${isFirst ? 'disabled' : ''}>▲</button>
|
title="${reorderTitle || 'Move up'}" ${isFirst ? 'disabled' : ''}>▲</button>
|
||||||
<button class="case-manage-btn" onclick="app.moveCaseDown(${escapeHtml(JSON.stringify(c.name))})"
|
<button class="case-manage-btn" onclick="app.moveCaseDown(${escapeHtml(JSON.stringify(c.name))})"
|
||||||
title="Move down" ${isLast ? 'disabled' : ''}>▼</button>
|
title="${reorderTitle || 'Move down'}" ${isLast ? 'disabled' : ''}>▼</button>
|
||||||
<button class="case-manage-btn case-manage-btn-delete" onclick="app.deleteCase(${escapeHtml(JSON.stringify(c.name))})"
|
<button class="case-manage-btn case-manage-btn-delete" onclick="app.deleteCase(${escapeHtml(JSON.stringify(c.name))})"
|
||||||
title="Delete case">✕</button>
|
title="Delete case">✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user