diff --git a/README.md b/README.md
index 502e759..b4f361d 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,7 @@ A mobile-first PWA for AI-assisted development. Chat with [Claude Code](https://
### Project Integration
- **File browser** - Browse and view files in the conversation's working directory
+- **Directory picker** - New-chat cwd chooser with breadcrumbs, fuzzy filtering, deep recursive search, favorites, and keyboard navigation
- **File preview** - View text/code, Markdown, JSON, images, CSV/TSV, Parquet, Jupyter notebooks, and geospatial files inline
- **Geospatial map viewer** - Interactive GeoJSON/JSONL map mode with Map/Raw toggle, basemap switcher, thematic styling, feature hover metadata, and fit-to-bounds controls
- **Git integration** - Full git workflow: status, stage/unstage, commit, push/pull, branches, stash
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 284c0be..1defce0 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -236,6 +236,7 @@ Conversations default to sandboxed mode for safety. Sandbox configuration:
| Method | Endpoint | Description |
|--------|----------|-------------|
| `GET` | `/api/browse` | Directory listing (cwd picker) |
+| `GET` | `/api/browse/search` | Recursive directory search for cwd picker fuzzy find |
| `GET` | `/api/files` | General file browser |
| `GET` | `/api/files/content` | Get structured file content (standalone cwd) |
| `GET` | `/api/files/download` | Download file |
diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md
index 70aa205..7a49916 100644
--- a/docs/REFERENCE.md
+++ b/docs/REFERENCE.md
@@ -19,7 +19,7 @@ lib/
index.js # Route setup
conversations.js # CRUD, export, fork, compress, search
git.js # Git operations
- files.js # File browser
+ files.js # File browser + cwd picker (browse + recursive search)
memory.js # Memory system
capabilities.js # Model/CLI capabilities
preview.js # Live web preview server controls
diff --git a/lib/routes/files.js b/lib/routes/files.js
index 760d0f4..e52b440 100644
--- a/lib/routes/files.js
+++ b/lib/routes/files.js
@@ -69,11 +69,159 @@ const BINARY_EXTS = new Set([
const MAX_FILE_SIZE = 500 * 1024; // 500KB
const MAX_GEO_FILE_SIZE = 20 * 1024 * 1024; // 20MB
const MAX_GEO_RAW_FILE_SIZE = MAX_FILE_SIZE; // Keep raw text preview conservative on large geo files.
+const BROWSE_SEARCH_DEFAULT_LIMIT = 50;
+const BROWSE_SEARCH_MAX_LIMIT = 200;
+const BROWSE_SEARCH_DEFAULT_DEPTH = 4;
+const BROWSE_SEARCH_MAX_DEPTH = 8;
+const BROWSE_SEARCH_MAX_SCANNED_DIRS = 5000;
+const BROWSE_SEARCH_SKIP_NAMES = new Set([
+ 'node_modules',
+ '.git',
+ '.svn',
+ '.hg',
+ '.idea',
+ '.vscode',
+ '.venv',
+ 'venv',
+ '__pycache__',
+ '.mypy_cache',
+ '.pytest_cache',
+ '.cache',
+]);
function getPreviewSizeLimitForExtension(ext) {
return LARGE_GEO_PREVIEW_EXTS.has(ext) ? MAX_GEO_FILE_SIZE : MAX_FILE_SIZE;
}
+function clampInteger(value, fallback, min, max) {
+ const parsed = Number.parseInt(value, 10);
+ if (!Number.isFinite(parsed)) return fallback;
+ return Math.min(max, Math.max(min, parsed));
+}
+
+function resolveBrowsePathInput(rawPath) {
+ const value = String(rawPath || '').trim();
+ if (!value) return process.env.HOME || '/';
+ if (value === '~') return process.env.HOME || '/';
+ if (value.startsWith('~/') && process.env.HOME) {
+ return path.resolve(process.env.HOME, value.slice(2));
+ }
+ return path.resolve(value);
+}
+
+function buildDirectorySearchScore(name, relPath, queryLower, tokens) {
+ const safeRel = String(relPath || '').replace(/\\/g, '/');
+ const nameLower = String(name || '').toLowerCase();
+ const relLower = safeRel.toLowerCase();
+
+ if (!relLower) return null;
+ if (tokens.length > 0 && !tokens.every((token) => relLower.includes(token))) return null;
+
+ let score = 0;
+ if (nameLower === queryLower) score += 120;
+ if (nameLower.startsWith(queryLower)) score += 85;
+ if (relLower.startsWith(queryLower)) score += 60;
+ if (nameLower.includes(queryLower)) score += 40;
+ if (relLower.includes(queryLower)) score += 25;
+
+ let ordered = true;
+ let cursor = 0;
+ for (const token of tokens) {
+ const next = relLower.indexOf(token, cursor);
+ if (next === -1) {
+ ordered = false;
+ break;
+ }
+ cursor = next + token.length;
+ }
+ if (ordered) score += 12;
+
+ score -= safeRel.length / 200;
+ return score;
+}
+
+async function searchDirectoriesRecursive(basePath, query, { limit, depth }) {
+ const root = path.resolve(basePath);
+ const queryLower = String(query || '').trim().toLowerCase();
+ const tokens = queryLower.split(/\s+/).filter(Boolean);
+ const queue = [{ absPath: root, relPath: '', depth: 0 }];
+ const visited = new Set([root]);
+ const results = [];
+ let queueIndex = 0;
+ let scannedDirs = 0;
+ let truncated = false;
+
+ while (queueIndex < queue.length) {
+ const current = queue[queueIndex++];
+ scannedDirs++;
+ if (scannedDirs > BROWSE_SEARCH_MAX_SCANNED_DIRS) {
+ truncated = true;
+ break;
+ }
+
+ let entries;
+ try {
+ entries = await fsp.readdir(current.absPath, { withFileTypes: true });
+ } catch (err) {
+ if (err?.code === 'EPERM' || err?.code === 'EACCES') {
+ continue;
+ }
+ throw err;
+ }
+
+ entries.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
+
+ for (const entry of entries) {
+ if (!entry.isDirectory()) continue;
+ if (entry.name.startsWith('.')) continue;
+ if (BROWSE_SEARCH_SKIP_NAMES.has(entry.name)) continue;
+
+ const childAbsPath = path.join(current.absPath, entry.name);
+ const childRelPath = current.relPath ? `${current.relPath}/${entry.name}` : entry.name;
+ const normalizedRelPath = childRelPath.replace(/\\/g, '/');
+ const score = buildDirectorySearchScore(entry.name, normalizedRelPath, queryLower, tokens);
+ if (score !== null) {
+ results.push({
+ path: childAbsPath,
+ relPath: normalizedRelPath,
+ name: entry.name,
+ score,
+ });
+ }
+
+ if (current.depth < depth && !visited.has(childAbsPath)) {
+ visited.add(childAbsPath);
+ queue.push({
+ absPath: childAbsPath,
+ relPath: normalizedRelPath,
+ depth: current.depth + 1,
+ });
+ }
+
+ if (results.length >= limit) {
+ truncated = true;
+ break;
+ }
+ }
+
+ if (results.length >= limit) break;
+ }
+
+ results.sort((a, b) => {
+ if (b.score !== a.score) return b.score - a.score;
+ return a.relPath.localeCompare(b.relPath, undefined, { sensitivity: 'base' });
+ });
+
+ return {
+ results: results.slice(0, limit).map(({ path: itemPath, relPath, name }) => ({
+ path: itemPath,
+ relPath,
+ name,
+ })),
+ truncated,
+ };
+}
+
/**
* Parse CSV/TSV file with row limit
* Returns { columns, rows, totalRows, truncated }
@@ -446,7 +594,7 @@ function setupFileRoutes(app) {
// Browse directories (for cwd picker)
app.get('/api/browse', async (req, res) => {
const target = req.query.path || process.env.HOME;
- const resolved = path.resolve(target);
+ const resolved = resolveBrowsePathInput(target);
try {
const entries = await fsp.readdir(resolved, { withFileTypes: true });
const dirs = entries
@@ -459,6 +607,41 @@ function setupFileRoutes(app) {
}
});
+ // Recursive directory search (for cwd picker fuzzy find)
+ app.get('/api/browse/search', async (req, res) => {
+ const baseInput = req.query.base || req.query.path;
+ const query = String(req.query.q || '').trim();
+ if (!baseInput) return res.status(400).json({ error: 'base required' });
+ if (!query) return res.status(400).json({ error: 'q required' });
+
+ const base = resolveBrowsePathInput(baseInput);
+ const limit = clampInteger(req.query.limit, BROWSE_SEARCH_DEFAULT_LIMIT, 1, BROWSE_SEARCH_MAX_LIMIT);
+ const depth = clampInteger(req.query.depth, BROWSE_SEARCH_DEFAULT_DEPTH, 1, BROWSE_SEARCH_MAX_DEPTH);
+
+ let baseStat;
+ try {
+ baseStat = await fsp.stat(base);
+ } catch (err) {
+ if (err?.code === 'EPERM' || err?.code === 'EACCES') {
+ return res.status(403).json({ error: 'Permission denied', base });
+ }
+ return res.status(404).json({ error: 'Base directory not found', base });
+ }
+ if (!baseStat.isDirectory()) {
+ return res.status(400).json({ error: 'base must be a directory', base });
+ }
+
+ try {
+ const { results, truncated } = await searchDirectoriesRecursive(base, query, { limit, depth });
+ res.json({ base, q: query, limit, depth, results, truncated });
+ } catch (err) {
+ if (err?.code === 'EPERM' || err?.code === 'EACCES') {
+ return res.status(403).json({ error: 'Permission denied', base });
+ }
+ res.status(500).json({ error: err.message || 'Directory search failed', base });
+ }
+ });
+
// General file browser
app.get('/api/files', async (req, res) => {
const targetPath = req.query.path || process.env.HOME;
diff --git a/public/css/components.css b/public/css/components.css
index 646e392..20d9f76 100644
--- a/public/css/components.css
+++ b/public/css/components.css
@@ -497,6 +497,11 @@
min-width: 0;
}
+.cwd-input-row input.input-error {
+ border-color: var(--danger);
+ box-shadow: 0 0 0 2px var(--danger-alpha-20, var(--danger-alpha-15));
+}
+
/* Recent directories */
.recent-dirs {
margin-top: 8px;
@@ -578,6 +583,10 @@
color: var(--accent-light);
}
+.dir-browser-header .btn-icon.active {
+ color: var(--accent);
+}
+
.dir-current-path {
font-size: 12px;
color: var(--text-secondary);
@@ -590,10 +599,114 @@
flex: 1;
}
+.dir-browser-search-row {
+ display: flex;
+ gap: 8px;
+ padding: 8px 10px;
+ border-bottom: 1px solid var(--border);
+ background: var(--bg);
+}
+
+.dir-filter-input {
+ flex: 1;
+ min-width: 0;
+ padding: 8px 10px;
+ background: var(--input-bg);
+ border: 1px solid var(--border);
+ border-radius: 8px;
+ color: var(--text);
+ font-size: 13px;
+ font-family: inherit;
+ outline: none;
+}
+
+.dir-filter-input:focus {
+ border-color: var(--accent);
+}
+
+.dir-breadcrumbs {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ flex-wrap: wrap;
+ padding: 8px 10px;
+ border-bottom: 1px solid var(--border);
+ background: var(--surface);
+}
+
+.dir-breadcrumb-btn {
+ border: none;
+ background: transparent;
+ color: var(--text-secondary);
+ font-size: 12px;
+ padding: 2px 4px;
+ border-radius: 6px;
+ cursor: pointer;
+}
+
+.dir-breadcrumb-btn:hover {
+ background: var(--bg-secondary);
+ color: var(--text);
+}
+
+.dir-breadcrumb-sep {
+ font-size: 11px;
+ color: var(--text-secondary);
+}
+
+.dir-section {
+ padding: 8px 10px 0;
+}
+
+.dir-section.hidden {
+ display: none;
+}
+
+.dir-section-title {
+ font-size: 11px;
+ color: var(--text-secondary);
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ margin-bottom: 6px;
+}
+
+.dir-chip-list {
+ display: flex;
+ gap: 6px;
+ flex-wrap: wrap;
+}
+
+.dir-chip {
+ border: 1px solid var(--border);
+ background: var(--surface);
+ color: var(--text);
+ border-radius: 999px;
+ padding: 4px 10px;
+ font-size: 12px;
+ cursor: pointer;
+ max-width: 180px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.dir-chip:hover {
+ border-color: var(--accent-alpha-35);
+ background: var(--bg-secondary);
+}
+
.dir-list {
max-height: 200px;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
+ margin-top: 8px;
+}
+
+.dir-list-compact {
+ max-height: 120px;
+ margin-top: 0;
+ border: 1px solid var(--border);
+ border-radius: 8px;
}
.dir-item {
@@ -605,12 +718,20 @@
color: var(--text);
cursor: pointer;
border-bottom: 1px solid var(--border);
+ width: 100%;
+ border-left: none;
+ border-right: none;
+ border-top: none;
+ background: transparent;
+ text-align: left;
+ font-family: inherit;
}
.dir-item:last-child {
border-bottom: none;
}
+.dir-item:hover,
.dir-item:active {
background: var(--surface);
}
@@ -627,6 +748,16 @@
min-width: 0;
}
+.dir-item.active {
+ background: var(--accent-alpha-15);
+ outline: 1px solid var(--accent-alpha-40);
+ outline-offset: -1px;
+}
+
+.dir-item-secondary {
+ font-size: 13px;
+}
+
.dir-empty {
padding: 16px;
text-align: center;
@@ -634,6 +765,22 @@
color: var(--text-secondary);
}
+.dir-search-note {
+ padding-top: 8px;
+ padding-bottom: 8px;
+}
+
+.dir-status {
+ min-height: 22px;
+ padding: 4px 10px 8px;
+ font-size: 12px;
+ color: var(--text-secondary);
+}
+
+.dir-status.error {
+ color: var(--danger);
+}
+
.dir-browser-actions {
display: flex;
gap: 8px;
diff --git a/public/index.html b/public/index.html
index 328b94e..eef58df 100644
--- a/public/index.html
+++ b/public/index.html
@@ -538,8 +538,27 @@
diff --git a/public/js/app.js b/public/js/app.js
index 86a92cd..54390ba 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -71,7 +71,18 @@ const browseBtn = document.getElementById('browse-btn');
const dirBrowser = document.getElementById('dir-browser');
const dirUpBtn = document.getElementById('dir-up-btn');
const dirCurrentPath = document.getElementById('dir-current-path');
+const dirFavoriteToggle = document.getElementById('dir-favorite-toggle');
+const dirFilterInput = document.getElementById('dir-filter-input');
+const dirDeepSearchBtn = document.getElementById('dir-deep-search-btn');
+const dirBreadcrumbs = document.getElementById('dir-breadcrumbs');
+const dirFavorites = document.getElementById('dir-favorites');
+const dirFavoritesList = document.getElementById('dir-favorites-list');
+const dirRecents = document.getElementById('dir-recents');
+const dirRecentsList = document.getElementById('dir-recents-list');
+const dirSearchResults = document.getElementById('dir-search-results');
+const dirSearchResultsList = document.getElementById('dir-search-results-list');
const dirList = document.getElementById('dir-list');
+const dirStatus = document.getElementById('dir-status');
const dirNewBtn = document.getElementById('dir-new-btn');
const dirSelectBtn = document.getElementById('dir-select-btn');
const micBtn = document.getElementById('mic-btn');
@@ -301,7 +312,18 @@ initUI({
dirBrowser,
dirUpBtn,
dirCurrentPath,
+ dirFavoriteToggle,
+ dirFilterInput,
+ dirDeepSearchBtn,
+ dirBreadcrumbs,
+ dirFavorites,
+ dirFavoritesList,
+ dirRecents,
+ dirRecentsList,
+ dirSearchResults,
+ dirSearchResultsList,
dirList,
+ dirStatus,
dirNewBtn,
dirSelectBtn,
micBtn,
diff --git a/public/js/ui.js b/public/js/ui.js
index 3308d28..8e8d091 100644
--- a/public/js/ui.js
+++ b/public/js/ui.js
@@ -516,7 +516,18 @@ export function initUI(elements) {
dirBrowser: elements.dirBrowser,
dirUpBtn: elements.dirUpBtn,
dirCurrentPath: elements.dirCurrentPath,
+ dirFavoriteToggle: elements.dirFavoriteToggle,
+ dirFilterInput: elements.dirFilterInput,
+ dirDeepSearchBtn: elements.dirDeepSearchBtn,
+ dirBreadcrumbs: elements.dirBreadcrumbs,
+ dirFavorites: elements.dirFavorites,
+ dirFavoritesList: elements.dirFavoritesList,
+ dirRecents: elements.dirRecents,
+ dirRecentsList: elements.dirRecentsList,
+ dirSearchResults: elements.dirSearchResults,
+ dirSearchResultsList: elements.dirSearchResultsList,
dirList: elements.dirList,
+ dirStatus: elements.dirStatus,
dirNewBtn: elements.dirNewBtn,
dirSelectBtn: elements.dirSelectBtn,
convCwdInput: elements.convCwdInput,
diff --git a/public/js/ui/directory-browser.js b/public/js/ui/directory-browser.js
index a413953..20d3687 100644
--- a/public/js/ui/directory-browser.js
+++ b/public/js/ui/directory-browser.js
@@ -1,103 +1,733 @@
// --- Directory browsing (for new conversation modal) ---
import { escapeHtml } from '../markdown.js';
-import { showDialog, apiFetch } from '../utils.js';
+import { showDialog } from '../utils.js';
import * as state from '../state.js';
+const FAVORITES_STORAGE_KEY = 'directoryFavorites:v1';
+const RECENTS_STORAGE_KEY = 'directoryRecentPaths:v1';
+const FAVORITES_MAX = 20;
+const RECENTS_MAX = 10;
+const DEEP_SEARCH_DEBOUNCE_MS = 200;
+const DEEP_SEARCH_MIN_QUERY_LENGTH = 2;
+
// DOM elements (set by init)
let browseBtn = null;
let dirBrowser = null;
let dirUpBtn = null;
let dirCurrentPath = null;
+let dirFavoriteToggle = null;
+let dirFilterInput = null;
+let dirDeepSearchBtn = null;
+let dirBreadcrumbs = null;
+let dirFavorites = null;
+let dirFavoritesList = null;
+let dirRecents = null;
+let dirRecentsList = null;
+let dirSearchResults = null;
+let dirSearchResultsList = null;
let dirList = null;
+let dirStatus = null;
let dirNewBtn = null;
let dirSelectBtn = null;
let convCwdInput = null;
-export function initDirectoryBrowser(elements) {
- browseBtn = elements.browseBtn;
- dirBrowser = elements.dirBrowser;
- dirUpBtn = elements.dirUpBtn;
- dirCurrentPath = elements.dirCurrentPath;
- dirList = elements.dirList;
- dirNewBtn = elements.dirNewBtn;
- dirSelectBtn = elements.dirSelectBtn;
- convCwdInput = elements.convCwdInput;
+let currentDirs = [];
+let filteredDirs = [];
+let activeDirIndex = -1;
+let deepSearchResults = [];
+let deepSearchTruncated = false;
+let deepSearchDebounceTimer = null;
+let deepSearchRequestId = 0;
+let deepSearchAbortController = null;
+
+function normalizePathValue(value) {
+ const raw = String(value || '').trim();
+ if (!raw) return '';
+ const compact = raw.replace(/\/+$/, '');
+ return compact || '/';
}
-// --- Directory browser ---
-export async function browseTo(dirPath) {
- const qs = dirPath ? `?path=${encodeURIComponent(dirPath)}` : '';
- const res = await apiFetch(`/api/browse${qs}`, { silent: true });
- if (!res) {
- dirList.innerHTML = `
Failed to browse
`;
+function joinPath(basePath, childName) {
+ const base = normalizePathValue(basePath);
+ if (!base || base === '/') return `/${childName}`;
+ return `${base}/${childName}`;
+}
+
+function getParentPath(currentPath) {
+ const normalized = normalizePathValue(currentPath);
+ if (!normalized || normalized === '/') return '/';
+ const idx = normalized.lastIndexOf('/');
+ if (idx <= 0) return '/';
+ return normalized.slice(0, idx);
+}
+
+function clampArray(values, limit) {
+ return values.slice(0, limit);
+}
+
+function readStoredPathList(key) {
+ try {
+ const value = localStorage.getItem(key);
+ if (!value) return [];
+ const parsed = JSON.parse(value);
+ if (!Array.isArray(parsed)) return [];
+ return parsed
+ .map((item) => normalizePathValue(item))
+ .filter(Boolean);
+ } catch {
+ return [];
+ }
+}
+
+function writeStoredPathList(key, values) {
+ try {
+ localStorage.setItem(key, JSON.stringify(values));
+ } catch {
+ // Ignore storage write failures (e.g. private mode quota restrictions).
+ }
+}
+
+function getFavoritePaths() {
+ return readStoredPathList(FAVORITES_STORAGE_KEY);
+}
+
+function setFavoritePaths(paths) {
+ writeStoredPathList(FAVORITES_STORAGE_KEY, clampArray(paths, FAVORITES_MAX));
+}
+
+function getRecentPaths() {
+ return readStoredPathList(RECENTS_STORAGE_KEY);
+}
+
+function setRecentPaths(paths) {
+ writeStoredPathList(RECENTS_STORAGE_KEY, clampArray(paths, RECENTS_MAX));
+}
+
+function upsertPathAtFront(values, item, maxSize) {
+ const normalized = normalizePathValue(item);
+ if (!normalized) return values;
+ const deduped = values.filter((entry) => normalizePathValue(entry) !== normalized);
+ return clampArray([normalized, ...deduped], maxSize);
+}
+
+function scoreLocalDirectory(name, queryLower, queryTokens) {
+ const target = String(name || '').toLowerCase();
+ if (!target) return null;
+ if (queryTokens.length > 0 && !queryTokens.every((token) => target.includes(token))) return null;
+
+ let score = 0;
+ if (target === queryLower) score += 100;
+ if (target.startsWith(queryLower)) score += 70;
+ if (target.includes(queryLower)) score += 30;
+
+ let ordered = true;
+ let cursor = 0;
+ for (const token of queryTokens) {
+ const idx = target.indexOf(token, cursor);
+ if (idx === -1) {
+ ordered = false;
+ break;
+ }
+ cursor = idx + token.length;
+ }
+ if (ordered) score += 8;
+
+ score -= target.length / 200;
+ return score;
+}
+
+function clearDeepSearchState() {
+ if (deepSearchDebounceTimer) {
+ clearTimeout(deepSearchDebounceTimer);
+ deepSearchDebounceTimer = null;
+ }
+ if (deepSearchAbortController) {
+ deepSearchAbortController.abort();
+ deepSearchAbortController = null;
+ }
+ deepSearchResults = [];
+ deepSearchTruncated = false;
+ renderDeepSearchResults();
+}
+
+function setStatusMessage(message, { error = false } = {}) {
+ if (!dirStatus) return;
+ dirStatus.textContent = message || '';
+ dirStatus.classList.toggle('error', !!error);
+}
+
+function setCwdInputValidity(valid) {
+ if (!convCwdInput) return;
+ convCwdInput.classList.toggle('input-error', !valid);
+ if (valid) {
+ convCwdInput.removeAttribute('aria-invalid');
+ } else {
+ convCwdInput.setAttribute('aria-invalid', 'true');
+ }
+}
+
+function buildBreadcrumbSegments(pathValue) {
+ const normalized = normalizePathValue(pathValue);
+ if (!normalized || normalized === '/') {
+ return [{ label: '/', path: '/' }];
+ }
+
+ if (normalized.startsWith('/')) {
+ const parts = normalized.slice(1).split('/').filter(Boolean);
+ const segments = [{ label: '/', path: '/' }];
+ let current = '/';
+ for (const part of parts) {
+ current = current === '/' ? `/${part}` : `${current}/${part}`;
+ segments.push({ label: part, path: current });
+ }
+ return segments;
+ }
+
+ // Fallback for non-posix paths.
+ return [{ label: normalized, path: normalized }];
+}
+
+function formatPathSummary(pathValue) {
+ const normalized = normalizePathValue(pathValue);
+ if (!normalized || normalized === '/') return '/';
+
+ const parts = normalized.split('/').filter(Boolean);
+ if (parts.length === 0) return '/';
+ return parts[parts.length - 1];
+}
+
+function renderBreadcrumbs() {
+ if (!dirBreadcrumbs) return;
+ const currentPath = state.getCurrentBrowsePath() || convCwdInput?.value || '';
+ const segments = buildBreadcrumbSegments(currentPath);
+ const hasRootSegment = segments.length > 0 && segments[0].label === '/';
+ dirBreadcrumbs.innerHTML = segments.map((segment, index) => {
+ const separator = hasRootSegment
+ ? (index > 1 ? '
/' : '')
+ : (index > 0 ? '
/' : '');
+ return (
+ separator
+ + `
'
+ );
+ }).join('');
+}
+
+function renderFavoriteToggle() {
+ if (!dirFavoriteToggle) return;
+ const currentPath = normalizePathValue(state.getCurrentBrowsePath());
+ const favorites = getFavoritePaths();
+ const isFavorite = !!currentPath && favorites.includes(currentPath);
+ dirFavoriteToggle.innerHTML = isFavorite ? '★' : '☆';
+ dirFavoriteToggle.setAttribute('aria-label', isFavorite ? 'Remove from favorites' : 'Add to favorites');
+ dirFavoriteToggle.title = isFavorite ? 'Remove from favorites' : 'Add to favorites';
+ dirFavoriteToggle.classList.toggle('active', isFavorite);
+}
+
+function renderPathChipSection(sectionEl, listEl, paths, sectionClass) {
+ if (!sectionEl || !listEl) return;
+ if (!paths.length) {
+ sectionEl.classList.add('hidden');
+ listEl.innerHTML = '';
return;
}
- const data = await res.json();
- if (data.error) {
- dirList.innerHTML = `
${escapeHtml(data.error)}
`;
+
+ sectionEl.classList.remove('hidden');
+ listEl.innerHTML = paths.map((entryPath) => {
+ const label = entryPath.split('/').filter(Boolean).pop() || entryPath;
+ return (
+ `
'
+ );
+ }).join('');
+}
+
+function renderFavorites() {
+ renderPathChipSection(dirFavorites, dirFavoritesList, getFavoritePaths(), 'dir-favorite-chip');
+}
+
+function renderRecents() {
+ const favorites = new Set(getFavoritePaths());
+ const recents = getRecentPaths().filter((entry) => !favorites.has(entry));
+ renderPathChipSection(dirRecents, dirRecentsList, recents, 'dir-recent-chip');
+}
+
+function updateActiveDirItem({ ensureVisible = false } = {}) {
+ if (!dirList) return;
+ const items = dirList.querySelectorAll('.dir-item');
+ if (!items.length) {
+ activeDirIndex = -1;
+ dirList.removeAttribute('aria-activedescendant');
return;
}
- state.setCurrentBrowsePath(data.path);
- dirCurrentPath.textContent = data.path;
- convCwdInput.value = data.path;
- if (data.dirs.length === 0) {
- dirList.innerHTML = '
No subdirectories
';
+ if (activeDirIndex < 0 || activeDirIndex >= items.length) activeDirIndex = 0;
+
+ items.forEach((item, index) => {
+ const isActive = index === activeDirIndex;
+ item.classList.toggle('active', isActive);
+ item.setAttribute('aria-selected', isActive ? 'true' : 'false');
+ if (isActive) {
+ dirList.setAttribute('aria-activedescendant', item.id);
+ if (ensureVisible && typeof item.scrollIntoView === 'function') {
+ item.scrollIntoView({ block: 'nearest' });
+ }
+ }
+ });
+}
+
+function renderLocalDirectoryList() {
+ if (!dirList) return;
+
+ const query = (dirFilterInput?.value || '').trim().toLowerCase();
+ if (!query) {
+ filteredDirs = [...currentDirs];
} else {
- dirList.innerHTML = data.dirs.map(d =>
- `
` +
- `📁` +
- `${escapeHtml(d)}` +
- `
`
- ).join('');
- dirList.querySelectorAll('.dir-item').forEach(item => {
- item.addEventListener('click', () => {
- browseTo(state.getCurrentBrowsePath() + '/' + item.dataset.name);
+ const tokens = query.split(/\s+/).filter(Boolean);
+ const ranked = currentDirs
+ .map((name) => ({ name, score: scoreLocalDirectory(name, query, tokens) }))
+ .filter((item) => item.score !== null)
+ .sort((a, b) => {
+ if (b.score !== a.score) return b.score - a.score;
+ return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
});
- });
+ filteredDirs = ranked.map((item) => item.name);
+ }
+
+ if (filteredDirs.length === 0) {
+ dirList.innerHTML = '
No folders in this location
';
+ activeDirIndex = -1;
+ dirList.removeAttribute('aria-activedescendant');
+ } else {
+ dirList.innerHTML = filteredDirs.map((name, index) => {
+ const fullPath = joinPath(state.getCurrentBrowsePath(), name);
+ return (
+ `
'
+ );
+ }).join('');
+ activeDirIndex = 0;
+ updateActiveDirItem();
+ }
+}
+
+function renderDeepSearchResults() {
+ if (!dirSearchResults || !dirSearchResultsList) return;
+
+ if (!deepSearchResults.length) {
+ dirSearchResults.classList.add('hidden');
+ dirSearchResultsList.innerHTML = '';
+ return;
+ }
+
+ dirSearchResults.classList.remove('hidden');
+ dirSearchResultsList.innerHTML = deepSearchResults.map((entry, index) => (
+ `
'
+ )).join('');
+
+ if (deepSearchTruncated) {
+ dirSearchResultsList.insertAdjacentHTML(
+ 'beforeend',
+ '
Showing top matches. Narrow query for more precise results.
'
+ );
+ }
+}
+
+async function fetchBrowseData(dirPath) {
+ const qs = dirPath ? `?path=${encodeURIComponent(dirPath)}` : '';
+ try {
+ const response = await fetch(`/api/browse${qs}`);
+ const payload = await response.json().catch(() => ({}));
+ if (!response.ok || payload.error) {
+ return { ok: false, error: payload.error || `Request failed (${response.status})`, path: payload.path };
+ }
+ return { ok: true, data: payload };
+ } catch (err) {
+ return { ok: false, error: err.message || 'Failed to browse directory' };
+ }
+}
+
+async function applyBrowseData(payload, { preserveFilter = false } = {}) {
+ state.setCurrentBrowsePath(payload.path);
+ currentDirs = Array.isArray(payload.dirs) ? [...payload.dirs] : [];
+ currentDirs.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' }));
+
+ if (dirCurrentPath) {
+ dirCurrentPath.textContent = formatPathSummary(payload.path);
+ dirCurrentPath.title = payload.path;
+ }
+ if (convCwdInput) convCwdInput.value = payload.path;
+ setCwdInputValidity(true);
+ if (!preserveFilter && dirFilterInput) dirFilterInput.value = '';
+
+ renderBreadcrumbs();
+ renderFavoriteToggle();
+ renderFavorites();
+ renderRecents();
+ renderLocalDirectoryList();
+ clearDeepSearchState();
+ setStatusMessage(currentDirs.length ? `${currentDirs.length} folder${currentDirs.length === 1 ? '' : 's'}` : 'No subdirectories');
+}
+
+// --- Directory browser ---
+export async function browseTo(dirPath, options = {}) {
+ const result = await fetchBrowseData(dirPath);
+ if (!result.ok) {
+ setStatusMessage(result.error || 'Failed to browse directory', { error: true });
+ return false;
+ }
+ await applyBrowseData(result.data, options);
+ return true;
+}
+
+async function browseToInputPath() {
+ const raw = convCwdInput?.value?.trim() || '';
+ if (!raw) return false;
+
+ const ok = await browseTo(raw, { preserveFilter: false });
+ if (!ok) {
+ setCwdInputValidity(false);
+ setStatusMessage('Directory not found or not accessible', { error: true });
+ return false;
+ }
+ setCwdInputValidity(true);
+ return true;
+}
+
+function addRecentPath(entryPath) {
+ const current = getRecentPaths();
+ const next = upsertPathAtFront(current, entryPath, RECENTS_MAX);
+ setRecentPaths(next);
+}
+
+function toggleCurrentFavorite() {
+ const currentPath = normalizePathValue(state.getCurrentBrowsePath());
+ if (!currentPath) return;
+
+ const favorites = getFavoritePaths();
+ const exists = favorites.includes(currentPath);
+ const next = exists
+ ? favorites.filter((entry) => entry !== currentPath)
+ : upsertPathAtFront(favorites, currentPath, FAVORITES_MAX);
+ setFavoritePaths(next);
+ renderFavoriteToggle();
+ renderFavorites();
+ renderRecents();
+}
+
+function confirmSelection() {
+ const currentPath = normalizePathValue(state.getCurrentBrowsePath()) || normalizePathValue(convCwdInput?.value);
+ if (!currentPath || !convCwdInput) return;
+ convCwdInput.value = currentPath;
+ addRecentPath(currentPath);
+ renderRecents();
+ dirBrowser.classList.add('hidden');
+}
+
+async function runDeepSearch(force = false) {
+ const base = normalizePathValue(state.getCurrentBrowsePath() || convCwdInput?.value);
+ const query = (dirFilterInput?.value || '').trim();
+ if (!base || query.length < DEEP_SEARCH_MIN_QUERY_LENGTH) {
+ clearDeepSearchState();
+ return;
+ }
+ if (!force && filteredDirs.length > 0) {
+ deepSearchResults = [];
+ deepSearchTruncated = false;
+ renderDeepSearchResults();
+ return;
+ }
+
+ if (deepSearchAbortController) deepSearchAbortController.abort();
+ const AbortControllerCtor = globalThis.AbortController;
+ if (!AbortControllerCtor) return;
+ const controller = new AbortControllerCtor();
+ deepSearchAbortController = controller;
+ const requestId = ++deepSearchRequestId;
+ setStatusMessage('Searching nested folders...');
+
+ const qs = new URLSearchParams({
+ base,
+ q: query,
+ limit: '50',
+ depth: '4',
+ });
+
+ try {
+ const response = await fetch(`/api/browse/search?${qs.toString()}`, { signal: controller.signal });
+ const payload = await response.json().catch(() => ({}));
+ if (requestId !== deepSearchRequestId) return;
+ if (!response.ok || payload.error) {
+ deepSearchResults = [];
+ deepSearchTruncated = false;
+ renderDeepSearchResults();
+ setStatusMessage(payload.error || `Search failed (${response.status})`, { error: true });
+ return;
+ }
+
+ deepSearchResults = Array.isArray(payload.results) ? payload.results : [];
+ deepSearchTruncated = !!payload.truncated;
+ renderDeepSearchResults();
+ if (deepSearchResults.length > 0) {
+ setStatusMessage(`Found ${deepSearchResults.length} nested match${deepSearchResults.length === 1 ? '' : 'es'}`);
+ } else {
+ setStatusMessage('No nested matches found');
+ }
+ } catch (err) {
+ if (err?.name === 'AbortError') return;
+ deepSearchResults = [];
+ deepSearchTruncated = false;
+ renderDeepSearchResults();
+ setStatusMessage(err.message || 'Directory search failed', { error: true });
+ }
+}
+
+function queueDeepSearch(force = false) {
+ if (deepSearchDebounceTimer) clearTimeout(deepSearchDebounceTimer);
+ deepSearchDebounceTimer = setTimeout(() => {
+ deepSearchDebounceTimer = null;
+ void runDeepSearch(force);
+ }, DEEP_SEARCH_DEBOUNCE_MS);
+}
+
+function setActiveIndex(nextIndex, { ensureVisible = false } = {}) {
+ if (filteredDirs.length === 0) {
+ activeDirIndex = -1;
+ updateActiveDirItem();
+ return;
+ }
+ activeDirIndex = Math.max(0, Math.min(filteredDirs.length - 1, nextIndex));
+ updateActiveDirItem({ ensureVisible });
+}
+
+function moveActiveIndex(delta) {
+ if (filteredDirs.length === 0) return;
+ const start = activeDirIndex < 0 ? 0 : activeDirIndex;
+ setActiveIndex(start + delta, { ensureVisible: true });
+}
+
+async function openActiveDirectory() {
+ if (filteredDirs.length === 0) return;
+ const index = activeDirIndex < 0 ? 0 : activeDirIndex;
+ const name = filteredDirs[index];
+ if (!name) return;
+ await browseTo(joinPath(state.getCurrentBrowsePath(), name));
+}
+
+async function openDirectoryBrowser() {
+ dirBrowser.classList.remove('hidden');
+ const seedPath = convCwdInput.value.trim() || state.getCurrentBrowsePath() || '';
+ const ok = await browseTo(seedPath);
+ if (!ok) {
+ await browseTo('');
}
+ dirFilterInput?.focus();
+}
+
+function renderSectionPathFromEvent(target, selector) {
+ const button = target.closest(selector);
+ if (!button) return null;
+ return button.dataset.path || null;
+}
+
+function handleDirectoryBrowserKeyboard(e) {
+ if (dirBrowser.classList.contains('hidden')) return;
+
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ dirBrowser.classList.add('hidden');
+ return;
+ }
+
+ if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
+ e.preventDefault();
+ confirmSelection();
+ return;
+ }
+
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ moveActiveIndex(1);
+ return;
+ }
+
+ if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ moveActiveIndex(-1);
+ return;
+ }
+
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ void openActiveDirectory();
+ return;
+ }
+
+ if (e.key === 'Backspace' && document.activeElement === dirFilterInput && !dirFilterInput.value.trim()) {
+ e.preventDefault();
+ void browseTo(getParentPath(state.getCurrentBrowsePath()));
+ }
+}
+
+function attachSectionPathHandler(containerEl, selector) {
+ if (!containerEl) return;
+ containerEl.addEventListener('click', (event) => {
+ const targetPath = renderSectionPathFromEvent(event.target, selector);
+ if (!targetPath) return;
+ void browseTo(targetPath);
+ });
+}
+
+export function initDirectoryBrowser(elements) {
+ browseBtn = elements.browseBtn;
+ dirBrowser = elements.dirBrowser;
+ dirUpBtn = elements.dirUpBtn;
+ dirCurrentPath = elements.dirCurrentPath;
+ dirFavoriteToggle = elements.dirFavoriteToggle;
+ dirFilterInput = elements.dirFilterInput;
+ dirDeepSearchBtn = elements.dirDeepSearchBtn;
+ dirBreadcrumbs = elements.dirBreadcrumbs;
+ dirFavorites = elements.dirFavorites;
+ dirFavoritesList = elements.dirFavoritesList;
+ dirRecents = elements.dirRecents;
+ dirRecentsList = elements.dirRecentsList;
+ dirSearchResults = elements.dirSearchResults;
+ dirSearchResultsList = elements.dirSearchResultsList;
+ dirList = elements.dirList;
+ dirStatus = elements.dirStatus;
+ dirNewBtn = elements.dirNewBtn;
+ dirSelectBtn = elements.dirSelectBtn;
+ convCwdInput = elements.convCwdInput;
+
+ currentDirs = [];
+ filteredDirs = [];
+ activeDirIndex = -1;
+ clearDeepSearchState();
}
// --- Event listener setup for directory browser elements ---
export function setupDirectoryBrowserEventListeners() {
+ if (!browseBtn || !dirBrowser) return;
+
browseBtn.addEventListener('click', () => {
const isHidden = dirBrowser.classList.contains('hidden');
if (isHidden) {
- dirBrowser.classList.remove('hidden');
- browseTo(convCwdInput.value.trim() || '');
+ void openDirectoryBrowser();
} else {
dirBrowser.classList.add('hidden');
}
});
- dirUpBtn.addEventListener('click', () => {
- const currentBrowsePath = state.getCurrentBrowsePath();
- if (currentBrowsePath && currentBrowsePath !== '/') {
- const parent = currentBrowsePath.replace(/\/[^/]+$/, '') || '/';
- browseTo(parent);
- }
+ dirUpBtn?.addEventListener('click', () => {
+ void browseTo(getParentPath(state.getCurrentBrowsePath()));
});
- dirSelectBtn.addEventListener('click', () => {
- convCwdInput.value = state.getCurrentBrowsePath();
- dirBrowser.classList.add('hidden');
+ dirFavoriteToggle?.addEventListener('click', () => {
+ toggleCurrentFavorite();
});
- dirNewBtn.addEventListener('click', async () => {
- const name = await showDialog({ title: 'New folder', input: true, placeholder: 'Folder name', confirmLabel: 'Create' });
- if (!name || !name.trim()) return;
- const newPath = state.getCurrentBrowsePath() + '/' + name.trim();
- const res = await apiFetch('/api/mkdir', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ path: newPath }),
+ dirSelectBtn?.addEventListener('click', () => {
+ confirmSelection();
+ });
+
+ dirNewBtn?.addEventListener('click', async () => {
+ const name = await showDialog({
+ title: 'New folder',
+ input: true,
+ placeholder: 'Folder name',
+ confirmLabel: 'Create'
});
- if (!res) return;
- const data = await res.json();
- if (data.ok) {
- browseTo(newPath);
+ if (!name || !name.trim()) return;
+ const newPath = joinPath(state.getCurrentBrowsePath(), name.trim());
+ let response;
+ try {
+ response = await fetch('/api/mkdir', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ path: newPath }),
+ });
+ } catch (err) {
+ setStatusMessage(err.message || 'Failed to create folder', { error: true });
+ return;
+ }
+
+ const data = await response.json().catch(() => ({}));
+ if (response.ok && data.ok) {
+ await browseTo(newPath);
+ addRecentPath(newPath);
+ renderRecents();
} else {
- showDialog({ title: 'Error', message: data.error || 'Failed to create folder' });
+ setStatusMessage(data.error || `Failed to create folder (${response.status})`, { error: true });
+ }
+ });
+
+ dirFilterInput?.addEventListener('input', () => {
+ renderLocalDirectoryList();
+ const query = dirFilterInput.value.trim();
+ if (query.length >= DEEP_SEARCH_MIN_QUERY_LENGTH && filteredDirs.length === 0) {
+ queueDeepSearch(false);
+ } else {
+ clearDeepSearchState();
+ const count = filteredDirs.length;
+ setStatusMessage(count ? `${count} folder${count === 1 ? '' : 's'}` : 'No folders in this location');
+ }
+ });
+
+ dirDeepSearchBtn?.addEventListener('click', () => {
+ queueDeepSearch(true);
+ });
+
+ dirBreadcrumbs?.addEventListener('click', (event) => {
+ const targetPath = renderSectionPathFromEvent(event.target, '.dir-breadcrumb-btn');
+ if (!targetPath) return;
+ void browseTo(targetPath);
+ });
+
+ attachSectionPathHandler(dirFavoritesList, '.dir-chip');
+ attachSectionPathHandler(dirRecentsList, '.dir-chip');
+ attachSectionPathHandler(dirSearchResultsList, '.dir-item');
+
+ dirList?.addEventListener('click', (event) => {
+ const item = event.target.closest('.dir-item');
+ if (!item) return;
+ const itemPath = item.dataset.path;
+ if (!itemPath) return;
+ void browseTo(itemPath);
+ });
+
+ dirList?.addEventListener('mousemove', (event) => {
+ const item = event.target.closest('.dir-item');
+ if (!item) return;
+ const index = Number.parseInt(item.id.replace('dir-item-', ''), 10);
+ if (Number.isFinite(index) && index !== activeDirIndex) {
+ activeDirIndex = index;
+ updateActiveDirItem();
+ }
+ });
+
+ dirBrowser.addEventListener('keydown', handleDirectoryBrowserKeyboard);
+
+ convCwdInput?.addEventListener('keydown', (event) => {
+ if (event.key !== 'Enter' || event.metaKey || event.ctrlKey) return;
+ event.preventDefault();
+ if (dirBrowser.classList.contains('hidden')) {
+ dirBrowser.classList.remove('hidden');
+ }
+ void browseToInputPath();
+ });
+
+ convCwdInput?.addEventListener('blur', () => {
+ if (!convCwdInput.value.trim()) {
+ setCwdInputValidity(true);
+ return;
}
+ void browseToInputPath();
});
}
diff --git a/test/files-routes.test.js b/test/files-routes.test.js
index 2230f9d..ccbbdd2 100644
--- a/test/files-routes.test.js
+++ b/test/files-routes.test.js
@@ -227,6 +227,68 @@ describe('file routes', () => {
assert.equal(typeof response.body.error, 'string');
});
+ it('validates browse search parameters', async () => {
+ const missingBase = await requestJson(baseUrl, 'GET', '/api/browse/search?q=data');
+ assert.equal(missingBase.status, 400);
+ assert.equal(missingBase.body.error, 'base required');
+
+ const missingQuery = await requestJson(baseUrl, 'GET', `/api/browse/search?base=${encodeURIComponent(tmpRoot)}`);
+ assert.equal(missingQuery.status, 400);
+ assert.equal(missingQuery.body.error, 'q required');
+ });
+
+ it('returns recursive directory search matches', async () => {
+ await fs.mkdir(path.join(tmpRoot, 'datasets', 'raw-data'), { recursive: true });
+ await fs.mkdir(path.join(tmpRoot, 'datasets', 'processed-data'), { recursive: true });
+ await fs.mkdir(path.join(tmpRoot, 'reports'), { recursive: true });
+
+ const response = await requestJson(
+ baseUrl,
+ 'GET',
+ `/api/browse/search?base=${encodeURIComponent(tmpRoot)}&q=${encodeURIComponent('data')}&depth=4&limit=10`
+ );
+ assert.equal(response.status, 200);
+ assert.equal(response.body.base, tmpRoot);
+ assert.equal(Array.isArray(response.body.results), true);
+ assert.equal(response.body.results.length >= 2, true);
+
+ const relPaths = response.body.results.map((item) => item.relPath);
+ assert.equal(relPaths.includes('datasets/raw-data'), true);
+ assert.equal(relPaths.includes('datasets/processed-data'), true);
+ });
+
+ it('skips hidden and heavy directories in browse search', async () => {
+ await fs.mkdir(path.join(tmpRoot, '.git', 'data-hidden'), { recursive: true });
+ await fs.mkdir(path.join(tmpRoot, 'node_modules', 'data-packages'), { recursive: true });
+ await fs.mkdir(path.join(tmpRoot, 'safe-data'), { recursive: true });
+
+ const response = await requestJson(
+ baseUrl,
+ 'GET',
+ `/api/browse/search?base=${encodeURIComponent(tmpRoot)}&q=${encodeURIComponent('data')}&depth=4&limit=20`
+ );
+ assert.equal(response.status, 200);
+ const relPaths = response.body.results.map((item) => item.relPath);
+ assert.equal(relPaths.includes('safe-data'), true);
+ assert.equal(relPaths.some((relPath) => relPath.includes('.git')), false);
+ assert.equal(relPaths.some((relPath) => relPath.includes('node_modules')), false);
+ });
+
+ it('enforces search result limits and marks truncation', async () => {
+ for (let i = 0; i < 8; i++) {
+ await fs.mkdir(path.join(tmpRoot, `data-${i}`), { recursive: true });
+ }
+
+ const response = await requestJson(
+ baseUrl,
+ 'GET',
+ `/api/browse/search?base=${encodeURIComponent(tmpRoot)}&q=${encodeURIComponent('data')}&depth=2&limit=3`
+ );
+ assert.equal(response.status, 200);
+ assert.equal(response.body.results.length <= 3, true);
+ assert.equal(response.body.truncated, true);
+ });
+
it('creates a directory through mkdir endpoint', async () => {
const target = path.join(tmpRoot, 'nested', 'folder');
const response = await requestJson(baseUrl, 'POST', '/api/mkdir', { path: target });