mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c50bb02e62 |
@@ -0,0 +1,23 @@
|
||||
---
|
||||
'aicodeman': patch
|
||||
---
|
||||
|
||||
The File Viewer can show hidden files and folders.
|
||||
|
||||
`GET /api/sessions/:id/files` has always accepted `showHidden=true`, but the panel
|
||||
hardcoded `showHidden=false`, so dot-prefixed entries were unreachable from the
|
||||
tree: no `.gitignore`, no `.github/`, no `.env.example`, and nothing under them.
|
||||
Opening one meant guessing its path.
|
||||
|
||||
The panel header gains a `.*` toggle. It re-fetches rather than re-rendering the
|
||||
cached tree, because the filtering happens server-side, and it keeps the expanded
|
||||
directories so toggling does not collapse the tree you just navigated. The state
|
||||
is per-device (its own `codeman:fileBrowserShowHidden` key rather than the
|
||||
app-settings object, which is rebuilt from the settings-modal DOM on save and
|
||||
would drop a key toggled from outside it), defaults to OFF, and survives a reload.
|
||||
|
||||
Generated and version-control directories (`.git`, `node_modules`, `.next`,
|
||||
`.venv`, ...) stay excluded either way: that list is about tree size, not about
|
||||
hiding dotfiles.
|
||||
|
||||
Closes #221.
|
||||
@@ -615,6 +615,11 @@ class CodemanApp {
|
||||
this.fileBrowserFilter = '';
|
||||
this.fileBrowserAllExpanded = false;
|
||||
this.fileBrowserDragListeners = null;
|
||||
// Show hidden (dot-prefixed) files and folders in the File Viewer tree.
|
||||
// Per-device, persisted to its own localStorage key by panels-ui.js. Safe to
|
||||
// call a mixin method here: instantiation is deferred to DOMContentLoaded,
|
||||
// so every module's Object.assign has already run.
|
||||
this.fileBrowserShowHidden = this._loadFileBrowserShowHidden?.() ?? false;
|
||||
this.filePreviewContent = '';
|
||||
|
||||
// Toast container cache (methods in panels-ui.js)
|
||||
|
||||
@@ -407,6 +407,7 @@
|
||||
<div class="file-browser-header">
|
||||
<span class="file-browser-title">Files</span>
|
||||
<div class="file-browser-actions">
|
||||
<button class="btn-icon-sm btn-file-browser-hidden" onclick="app.toggleFileBrowserHidden()" title="Show hidden files and folders" aria-label="Show hidden files and folders" aria-pressed="false" id="fileBrowserHiddenBtn">.*</button>
|
||||
<button class="btn-icon-sm" onclick="app.refreshFileBrowser()" title="Refresh">↻</button>
|
||||
<button class="btn-icon-sm" onclick="app.toggleFileBrowserExpand()" title="Expand/Collapse All" id="fileBrowserExpandBtn">⊞</button>
|
||||
<button class="btn-icon-sm" onclick="app.closeFileBrowserPanel()" title="Close">×</button>
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
*/
|
||||
|
||||
const AWAY_DIGEST_LAST_VIEWED_KEY = 'codeman-away-digest-last-viewed';
|
||||
const FILE_BROWSER_SHOW_HIDDEN_KEY = 'codeman:fileBrowserShowHidden';
|
||||
const AWAY_DIGEST_SECTIONS = [
|
||||
['needsAttention', 'Needs Attention'],
|
||||
['completed', 'Completed'],
|
||||
@@ -2944,18 +2945,56 @@ Object.assign(CodemanApp.prototype, {
|
||||
// File Browser Panel
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
// Hidden files/folders (dot-prefixed) are filtered SERVER-side by
|
||||
// GET /api/sessions/:id/files, so the toggle re-fetches rather than
|
||||
// re-rendering the cached tree (issue #221). The flag is per-device and lives
|
||||
// in its own localStorage key instead of the app-settings object: that object
|
||||
// is rebuilt from the settings-modal DOM on every save, so a key toggled from
|
||||
// outside the modal would be dropped the next time settings are saved.
|
||||
_loadFileBrowserShowHidden() {
|
||||
try {
|
||||
return localStorage.getItem(FILE_BROWSER_SHOW_HIDDEN_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
_syncFileBrowserHiddenBtn() {
|
||||
const btn = this.$('fileBrowserHiddenBtn');
|
||||
if (!btn) return;
|
||||
const on = this.fileBrowserShowHidden === true;
|
||||
btn.classList.toggle('active', on);
|
||||
btn.setAttribute('aria-pressed', String(on));
|
||||
const label = on ? 'Hide hidden files and folders' : 'Show hidden files and folders';
|
||||
btn.setAttribute('title', label);
|
||||
btn.setAttribute('aria-label', label);
|
||||
},
|
||||
|
||||
async toggleFileBrowserHidden() {
|
||||
this.fileBrowserShowHidden = !this.fileBrowserShowHidden;
|
||||
try {
|
||||
localStorage.setItem(FILE_BROWSER_SHOW_HIDDEN_KEY, this.fileBrowserShowHidden ? '1' : '0');
|
||||
} catch {}
|
||||
this._syncFileBrowserHiddenBtn();
|
||||
// Expanded-directory state is deliberately preserved so toggling does not
|
||||
// collapse the tree the user just navigated.
|
||||
if (this.activeSessionId) await this.loadFileBrowser(this.activeSessionId);
|
||||
},
|
||||
|
||||
async loadFileBrowser(sessionId) {
|
||||
if (!sessionId) return;
|
||||
|
||||
const treeEl = this.$('fileBrowserTree');
|
||||
const statusEl = this.$('fileBrowserStatus');
|
||||
this._syncFileBrowserHiddenBtn();
|
||||
if (!treeEl) return;
|
||||
|
||||
// Show loading state
|
||||
treeEl.innerHTML = '<div class="file-browser-loading">Loading files...</div>';
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${sessionId}/files?depth=5&showHidden=false`);
|
||||
const showHidden = this.fileBrowserShowHidden === true;
|
||||
const res = await fetch(`/api/sessions/${sessionId}/files?depth=5&showHidden=${showHidden}`);
|
||||
if (!res.ok) throw new Error('Failed to load files');
|
||||
|
||||
const result = await res.json();
|
||||
@@ -2967,7 +3006,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
// Update status
|
||||
if (statusEl) {
|
||||
const { totalFiles, totalDirectories, truncated } = result.data;
|
||||
statusEl.textContent = `${totalFiles} files, ${totalDirectories} dirs${truncated ? ' (truncated)' : ''}`;
|
||||
statusEl.textContent = `${totalFiles} files, ${totalDirectories} dirs${truncated ? ' (truncated)' : ''}${showHidden ? ' · hidden shown' : ''}`;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load file browser:', err);
|
||||
|
||||
@@ -9192,6 +9192,20 @@ kbd {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* Show-hidden toggle: a literal `.*` glyph rather than an icon, so its meaning
|
||||
* (dot-prefixed files and folders) survives every skin and font stack. */
|
||||
.btn-file-browser-hidden {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.05em;
|
||||
}
|
||||
|
||||
.btn-file-browser-hidden.active {
|
||||
color: var(--accent);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.file-browser-search {
|
||||
padding: 0.4rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* @fileoverview File Viewer "show hidden" toggle (issue #221).
|
||||
*
|
||||
* Hidden (dot-prefixed) entries are filtered SERVER-side by
|
||||
* `GET /api/sessions/:id/files`, which has always accepted `showHidden=true`;
|
||||
* the frontend simply hardcoded `showHidden=false`. So the whole feature is the
|
||||
* client honouring a persisted per-device flag, and the things that can silently
|
||||
* break it are:
|
||||
*
|
||||
* 1. the request going out with the wrong `showHidden` value (the toggle looks
|
||||
* dead: the button lights up, the tree does not change),
|
||||
* 2. the toggle re-rendering the cached tree instead of re-fetching (same
|
||||
* symptom, and no request in the network tab to explain it),
|
||||
* 3. toggling collapsing the tree the user just navigated,
|
||||
* 4. the flag not surviving a reload, or a `localStorage` throw (Safari private
|
||||
* mode) taking the whole panel down with it.
|
||||
*
|
||||
* Loaded via `vm` with a stubbed context (no jsdom; see connection-indicator.test.ts).
|
||||
* `CodemanApp`'s real constructor calls `init()`, so the prototype is exercised on
|
||||
* a bare object instead of a real instance; the app.js wiring that seeds the flag
|
||||
* is pinned statically at the bottom.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
|
||||
const panelsJs = readFileSync(resolve(PUBLIC, 'panels-ui.js'), 'utf8');
|
||||
const appJs = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
|
||||
const indexHtml = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
|
||||
const stylesCss = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
|
||||
|
||||
const STORAGE_KEY = 'codeman:fileBrowserShowHidden';
|
||||
|
||||
interface FakeElement {
|
||||
innerHTML: string;
|
||||
textContent: string;
|
||||
classes: Set<string>;
|
||||
attrs: Record<string, string>;
|
||||
classList: { toggle: (name: string, on: boolean) => void };
|
||||
setAttribute: (name: string, value: string) => void;
|
||||
}
|
||||
|
||||
function fakeElement(): FakeElement {
|
||||
const classes = new Set<string>();
|
||||
const attrs: Record<string, string> = {};
|
||||
return {
|
||||
innerHTML: '',
|
||||
textContent: '',
|
||||
classes,
|
||||
attrs,
|
||||
classList: {
|
||||
toggle(name: string, on: boolean) {
|
||||
if (on) classes.add(name);
|
||||
else classes.delete(name);
|
||||
},
|
||||
},
|
||||
setAttribute(name: string, value: string) {
|
||||
attrs[name] = value;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Load panels-ui.js's mixin onto a bare object, with a stubbed DOM + storage. */
|
||||
function loadPanel(store: Map<string, string> | null) {
|
||||
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, unknown>;
|
||||
const localStorage = {
|
||||
getItem: (key: string) => {
|
||||
if (!store) throw new Error('localStorage is disabled');
|
||||
return store.has(key) ? store.get(key) : null;
|
||||
},
|
||||
setItem: (key: string, value: string) => {
|
||||
if (!store) throw new Error('localStorage is disabled');
|
||||
store.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => store?.delete(key),
|
||||
};
|
||||
const context = vm.createContext({
|
||||
CodemanApp,
|
||||
console,
|
||||
localStorage,
|
||||
escapeHtml: (s: string) => String(s),
|
||||
document: { getElementById: () => null, addEventListener: vi.fn() },
|
||||
window: { addEventListener: vi.fn() },
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
fetch: () => {
|
||||
throw new Error('fetch not stubbed');
|
||||
},
|
||||
});
|
||||
vm.runInContext(panelsJs, context, { filename: 'panels-ui.js' });
|
||||
|
||||
const elements: Record<string, FakeElement> = {
|
||||
fileBrowserTree: fakeElement(),
|
||||
fileBrowserStatus: fakeElement(),
|
||||
fileBrowserHiddenBtn: fakeElement(),
|
||||
};
|
||||
const requests: string[] = [];
|
||||
const app = new CodemanApp() as Record<string, any>;
|
||||
app.$ = (id: string) => elements[id] ?? null;
|
||||
app.activeSessionId = 'sess-1';
|
||||
app.fileBrowserData = null;
|
||||
app.fileBrowserExpandedDirs = new Set<string>();
|
||||
app.fileBrowserFilter = '';
|
||||
app.fileBrowserShowHidden = app._loadFileBrowserShowHidden();
|
||||
// Mirror app.js: fetch is a global in the browser, a per-app stub here.
|
||||
context.fetch = async (url: string) => {
|
||||
requests.push(url);
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: { tree: [], totalFiles: 3, totalDirectories: 1, truncated: false },
|
||||
}),
|
||||
};
|
||||
};
|
||||
return { app, elements, requests };
|
||||
}
|
||||
|
||||
describe('File Viewer show-hidden toggle', () => {
|
||||
let store: Map<string, string>;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new Map();
|
||||
});
|
||||
|
||||
it('requests showHidden=false by default', async () => {
|
||||
const { app, requests } = loadPanel(store);
|
||||
expect(app.fileBrowserShowHidden).toBe(false);
|
||||
|
||||
await app.loadFileBrowser('sess-1');
|
||||
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(requests[0]).toContain('showHidden=false');
|
||||
});
|
||||
|
||||
it('restores an enabled toggle from localStorage and requests showHidden=true', async () => {
|
||||
store.set(STORAGE_KEY, '1');
|
||||
const { app, requests } = loadPanel(store);
|
||||
expect(app.fileBrowserShowHidden).toBe(true);
|
||||
|
||||
await app.loadFileBrowser('sess-1');
|
||||
|
||||
expect(requests[0]).toContain('showHidden=true');
|
||||
});
|
||||
|
||||
it('re-fetches the tree when toggled, since hidden entries are filtered server-side', async () => {
|
||||
const { app, requests } = loadPanel(store);
|
||||
await app.loadFileBrowser('sess-1');
|
||||
expect(requests[0]).toContain('showHidden=false');
|
||||
|
||||
await app.toggleFileBrowserHidden();
|
||||
|
||||
expect(app.fileBrowserShowHidden).toBe(true);
|
||||
expect(requests).toHaveLength(2);
|
||||
expect(requests[1]).toContain('showHidden=true');
|
||||
expect(store.get(STORAGE_KEY)).toBe('1');
|
||||
});
|
||||
|
||||
it('toggles back off and persists the off state', async () => {
|
||||
store.set(STORAGE_KEY, '1');
|
||||
const { app, requests } = loadPanel(store);
|
||||
|
||||
await app.toggleFileBrowserHidden();
|
||||
|
||||
expect(app.fileBrowserShowHidden).toBe(false);
|
||||
expect(store.get(STORAGE_KEY)).toBe('0');
|
||||
expect(requests[0]).toContain('showHidden=false');
|
||||
});
|
||||
|
||||
it('keeps expanded directories across a toggle', async () => {
|
||||
const { app } = loadPanel(store);
|
||||
app.fileBrowserExpandedDirs.add('src');
|
||||
app.fileBrowserExpandedDirs.add('src/web');
|
||||
|
||||
await app.toggleFileBrowserHidden();
|
||||
|
||||
expect([...app.fileBrowserExpandedDirs]).toEqual(['src', 'src/web']);
|
||||
});
|
||||
|
||||
it('reflects state on the button and in the status line', async () => {
|
||||
const { app, elements } = loadPanel(store);
|
||||
const btn = elements.fileBrowserHiddenBtn;
|
||||
|
||||
await app.loadFileBrowser('sess-1');
|
||||
expect(btn.classes.has('active')).toBe(false);
|
||||
expect(btn.attrs['aria-pressed']).toBe('false');
|
||||
expect(btn.attrs.title).toBe('Show hidden files and folders');
|
||||
expect(elements.fileBrowserStatus.textContent).not.toContain('hidden shown');
|
||||
|
||||
await app.toggleFileBrowserHidden();
|
||||
expect(btn.classes.has('active')).toBe(true);
|
||||
expect(btn.attrs['aria-pressed']).toBe('true');
|
||||
expect(btn.attrs.title).toBe('Hide hidden files and folders');
|
||||
expect(btn.attrs['aria-label']).toBe('Hide hidden files and folders');
|
||||
expect(elements.fileBrowserStatus.textContent).toContain('hidden shown');
|
||||
});
|
||||
|
||||
it('survives a localStorage that throws (private browsing)', async () => {
|
||||
const { app, requests } = loadPanel(null);
|
||||
expect(app.fileBrowserShowHidden).toBe(false);
|
||||
|
||||
await app.toggleFileBrowserHidden();
|
||||
|
||||
expect(app.fileBrowserShowHidden).toBe(true);
|
||||
expect(requests[0]).toContain('showHidden=true');
|
||||
});
|
||||
|
||||
it('does not reset the preference on a panel refresh', async () => {
|
||||
store.set(STORAGE_KEY, '1');
|
||||
const { app, requests } = loadPanel(store);
|
||||
|
||||
app.refreshFileBrowser();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(app.fileBrowserShowHidden).toBe(true);
|
||||
expect(requests[0]).toContain('showHidden=true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('File Viewer show-hidden wiring', () => {
|
||||
it('exposes the toggle in the file browser header', () => {
|
||||
expect(indexHtml).toContain('onclick="app.toggleFileBrowserHidden()"');
|
||||
expect(indexHtml).toContain('id="fileBrowserHiddenBtn"');
|
||||
expect(indexHtml).toContain('aria-pressed="false"');
|
||||
});
|
||||
|
||||
it('seeds the flag from storage when the app is constructed', () => {
|
||||
expect(appJs).toMatch(/this\.fileBrowserShowHidden\s*=\s*this\._loadFileBrowserShowHidden\?\.\(\)/);
|
||||
});
|
||||
|
||||
it('styles the active state so the toggle reads as on', () => {
|
||||
expect(stylesCss).toContain('.btn-file-browser-hidden.active');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user