Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion docs/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
185 changes: 184 additions & 1 deletion lib/routes/files.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down
Loading