diff --git a/.changeset/file-viewer-show-hidden.md b/.changeset/file-viewer-show-hidden.md new file mode 100644 index 00000000..cab3ddf1 --- /dev/null +++ b/.changeset/file-viewer-show-hidden.md @@ -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. diff --git a/src/web/public/app.js b/src/web/public/app.js index f4f95e23..e04b7088 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -622,6 +622,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) diff --git a/src/web/public/index.html b/src/web/public/index.html index bba6dbd6..18c19fc3 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -411,6 +411,7 @@
Files
+ diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 8952e209..1aedf9df 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -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 = '
Loading files...
'; 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); diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 5d950611..bbdeef9a 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -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); diff --git a/test/file-browser-hidden.test.ts b/test/file-browser-hidden.test.ts new file mode 100644 index 00000000..f5817fc3 --- /dev/null +++ b/test/file-browser-hidden.test.ts @@ -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; + attrs: Record; + classList: { toggle: (name: string, on: boolean) => void }; + setAttribute: (name: string, value: string) => void; +} + +function fakeElement(): FakeElement { + const classes = new Set(); + const attrs: Record = {}; + 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 | null) { + const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record; + 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 = { + fileBrowserTree: fakeElement(), + fileBrowserStatus: fakeElement(), + fileBrowserHiddenBtn: fakeElement(), + }; + const requests: string[] = []; + const app = new CodemanApp() as Record; + app.$ = (id: string) => elements[id] ?? null; + app.activeSessionId = 'sess-1'; + app.fileBrowserData = null; + app.fileBrowserExpandedDirs = new Set(); + 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; + + 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'); + }); +});