mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
feat(mobile): add filesystem path picker
This commit is contained in:
@@ -9,6 +9,7 @@
|
||||
* - CleanupRegistration / CleanupResourceType — entries for the centralized CleanupManager
|
||||
* - NiceConfig / DEFAULT_NICE_CONFIG — process priority settings for `nice`/`ionice`
|
||||
* - ProcessStats — memory/CPU/child-count snapshot for resource monitoring
|
||||
* - FilesystemBrowseData — bounded path-picker directory listing returned to the web UI
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -68,6 +69,31 @@ export interface ProcessStats {
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/** A selectable entry returned by the filesystem path-picker API. */
|
||||
export interface FilesystemBrowseEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
type: 'file' | 'directory';
|
||||
size?: number;
|
||||
symlink?: boolean;
|
||||
}
|
||||
|
||||
/** A named root the path picker may browse without escaping its allowlist. */
|
||||
export interface FilesystemBrowseRoot {
|
||||
label: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
/** Response payload for `GET /api/filesystem/browse`. */
|
||||
export interface FilesystemBrowseData {
|
||||
path: string;
|
||||
parent: string | null;
|
||||
root: string;
|
||||
roots: FilesystemBrowseRoot[];
|
||||
entries: FilesystemBrowseEntry[];
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export type CleanupResourceType = 'timer' | 'interval' | 'watcher' | 'listener' | 'stream';
|
||||
|
||||
/**
|
||||
|
||||
@@ -1962,8 +1962,11 @@
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Folder Path</label>
|
||||
<input type="text" id="linkCasePath" placeholder="/home/user/projects/my-project" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||
<span class="form-hint">Absolute path to an existing project folder, e.g. /home/you/my-project</span>
|
||||
<div class="path-input-group">
|
||||
<input type="text" id="linkCasePath" placeholder="/mnt/d/AI/my-project" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
|
||||
<button type="button" class="btn path-input-browse" onclick="app.openLinkCasePathPicker()">Browse…</button>
|
||||
</div>
|
||||
<span class="form-hint">Choose an existing folder from this computer or enter its absolute path</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Remote Tab -->
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* @fileoverview Mobile keyboard accessory bar and modal focus trap.
|
||||
*
|
||||
* Defines two exports:
|
||||
* Defines three exports:
|
||||
*
|
||||
* - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual
|
||||
* keyboard on mobile: arrow up/down, /init, /clear, /compact, paste, Esc, and dismiss.
|
||||
@@ -10,12 +10,15 @@
|
||||
* Destructive actions (/clear, /compact) require double-tap confirmation (2s amber state).
|
||||
* Commands are sent as text + Enter separately for Ink compatibility.
|
||||
* Only initializes on touch devices (MobileDetection.isTouchDevice guard).
|
||||
* - PathPicker (singleton object) — Lazy server-side file/folder browser shared
|
||||
* by Link Existing and the extended mobile keyboard bar.
|
||||
*
|
||||
* - FocusTrap (class) — Traps Tab/Shift+Tab keyboard focus within a modal element.
|
||||
* Saves and restores previously focused element on deactivate. Used by Ralph wizard
|
||||
* and other modal dialogs.
|
||||
*
|
||||
* @globals {object} KeyboardAccessoryBar
|
||||
* @globals {object} PathPicker
|
||||
* @globals {class} FocusTrap
|
||||
*
|
||||
* @dependency mobile-handlers.js (MobileDetection.isTouchDevice)
|
||||
@@ -26,6 +29,227 @@
|
||||
// Codeman — Keyboard accessory bar and focus trap for modals
|
||||
// Loaded after mobile-handlers.js, before app.js
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Shared Filesystem Path Picker
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const PathPicker = {
|
||||
overlay: null,
|
||||
_options: null,
|
||||
_selectedPath: '',
|
||||
_previousFocus: null,
|
||||
_keydownHandler: null,
|
||||
_loadSequence: 0,
|
||||
|
||||
/**
|
||||
* Open the lazy filesystem browser.
|
||||
* @param {{sessionId?: string, initialPath?: string, directoriesOnly?: boolean,
|
||||
* title?: string, onSelect: (path: string) => void}} options
|
||||
*/
|
||||
open(options) {
|
||||
this.close(false);
|
||||
this._options = options;
|
||||
this._selectedPath = '';
|
||||
this._previousFocus = document.activeElement;
|
||||
this._previousFocus?.blur?.();
|
||||
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'path-picker-overlay';
|
||||
overlay.setAttribute('role', 'dialog');
|
||||
overlay.setAttribute('aria-modal', 'true');
|
||||
overlay.setAttribute('aria-label', options.title || 'Select a path');
|
||||
overlay.innerHTML = `
|
||||
<div class="path-picker-dialog">
|
||||
<div class="path-picker-header">
|
||||
<strong class="path-picker-title"></strong>
|
||||
<button type="button" class="path-picker-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="path-picker-roots-row">
|
||||
<label for="pathPickerRoot">Location</label>
|
||||
<select id="pathPickerRoot" class="path-picker-roots"></select>
|
||||
</div>
|
||||
<div class="path-picker-nav">
|
||||
<button type="button" class="path-picker-up" title="Parent folder" aria-label="Parent folder">↑</button>
|
||||
<div class="path-picker-current" title="Current folder"></div>
|
||||
<button type="button" class="path-picker-refresh" title="Refresh" aria-label="Refresh">↻</button>
|
||||
</div>
|
||||
<div class="path-picker-status" aria-live="polite">Loading...</div>
|
||||
<div class="path-picker-list" role="listbox"></div>
|
||||
<div class="path-picker-selection">
|
||||
<span class="path-picker-selection-label">Selected</span>
|
||||
<span class="path-picker-selection-value">None</span>
|
||||
</div>
|
||||
<div class="path-picker-actions">
|
||||
<button type="button" class="path-picker-current-select">Select Current Folder</button>
|
||||
<span class="path-picker-action-spacer"></span>
|
||||
<button type="button" class="path-picker-cancel">Cancel</button>
|
||||
<button type="button" class="path-picker-confirm" disabled>Select</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
this.overlay = overlay;
|
||||
overlay.querySelector('.path-picker-title').textContent = options.title || 'Select a Path';
|
||||
overlay.querySelector('.path-picker-close').addEventListener('click', () => this.close(true));
|
||||
overlay.querySelector('.path-picker-cancel').addEventListener('click', () => this.close(true));
|
||||
overlay.querySelector('.path-picker-confirm').addEventListener('click', () => this.confirm());
|
||||
overlay.querySelector('.path-picker-current-select').addEventListener('click', () => {
|
||||
const current = overlay.querySelector('.path-picker-current').textContent;
|
||||
if (current) this.select(current);
|
||||
});
|
||||
overlay.querySelector('.path-picker-refresh').addEventListener('click', () => this.load());
|
||||
overlay.querySelector('.path-picker-up').addEventListener('click', () => {
|
||||
const parent = overlay.querySelector('.path-picker-up').dataset.parent;
|
||||
if (parent) this.load(parent);
|
||||
});
|
||||
overlay.querySelector('.path-picker-roots').addEventListener('change', (event) => this.load(event.target.value));
|
||||
overlay.addEventListener('click', (event) => {
|
||||
if (event.target === overlay) this.close(true);
|
||||
});
|
||||
this._keydownHandler = (event) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
this.close(true);
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', this._keydownHandler);
|
||||
document.body.appendChild(overlay);
|
||||
this.load(options.initialPath || '');
|
||||
},
|
||||
|
||||
async load(path) {
|
||||
if (!this.overlay || !this._options) return;
|
||||
const loadSequence = ++this._loadSequence;
|
||||
const list = this.overlay.querySelector('.path-picker-list');
|
||||
const status = this.overlay.querySelector('.path-picker-status');
|
||||
list.replaceChildren();
|
||||
status.textContent = 'Loading...';
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (path) params.set('path', path);
|
||||
if (this._options.sessionId) params.set('sessionId', this._options.sessionId);
|
||||
try {
|
||||
const response = await fetch(`/api/filesystem/browse?${params.toString()}`);
|
||||
const result = await response.json();
|
||||
if (!response.ok || !result.success) throw new Error(result.error || 'Failed to browse this folder');
|
||||
if (!this.overlay || loadSequence !== this._loadSequence) return;
|
||||
this.render(result.data);
|
||||
} catch (error) {
|
||||
if (!this.overlay || loadSequence !== this._loadSequence) return;
|
||||
if (path) {
|
||||
this.load('');
|
||||
return;
|
||||
}
|
||||
status.textContent = error.message || 'Failed to browse this folder';
|
||||
status.classList.add('error');
|
||||
}
|
||||
},
|
||||
|
||||
render(data) {
|
||||
const rootSelect = this.overlay.querySelector('.path-picker-roots');
|
||||
rootSelect.replaceChildren();
|
||||
for (const root of data.roots) {
|
||||
const option = document.createElement('option');
|
||||
option.value = root.path;
|
||||
option.textContent = `${root.label} — ${root.path}`;
|
||||
option.selected = data.path === root.path || data.root === root.path;
|
||||
rootSelect.appendChild(option);
|
||||
}
|
||||
|
||||
this.overlay.querySelector('.path-picker-current').textContent = data.path;
|
||||
const up = this.overlay.querySelector('.path-picker-up');
|
||||
up.dataset.parent = data.parent || '';
|
||||
up.disabled = !data.parent;
|
||||
const status = this.overlay.querySelector('.path-picker-status');
|
||||
status.classList.remove('error');
|
||||
status.textContent = data.entries.length === 0
|
||||
? 'This folder is empty'
|
||||
: `${data.entries.length} item${data.entries.length === 1 ? '' : 's'}${data.truncated ? ' (first 500)' : ''}`;
|
||||
|
||||
const list = this.overlay.querySelector('.path-picker-list');
|
||||
list.replaceChildren();
|
||||
for (const entry of data.entries) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'path-picker-item';
|
||||
if (entry.type === 'file' && this._options.directoriesOnly) row.classList.add('not-selectable');
|
||||
row.dataset.path = entry.path;
|
||||
row.dataset.type = entry.type;
|
||||
row.setAttribute('role', 'option');
|
||||
|
||||
const open = document.createElement('button');
|
||||
open.type = 'button';
|
||||
open.className = 'path-picker-item-main';
|
||||
const icon = document.createElement('span');
|
||||
icon.className = 'path-picker-item-icon';
|
||||
icon.textContent = entry.type === 'directory' ? '\uD83D\uDCC1' : '\uD83D\uDCC4';
|
||||
const name = document.createElement('span');
|
||||
name.className = 'path-picker-item-name';
|
||||
name.textContent = entry.name;
|
||||
open.append(icon, name);
|
||||
if (entry.symlink) {
|
||||
const link = document.createElement('span');
|
||||
link.className = 'path-picker-item-link';
|
||||
link.textContent = '\u2197';
|
||||
open.appendChild(link);
|
||||
}
|
||||
if (entry.type === 'directory') {
|
||||
const chevron = document.createElement('span');
|
||||
chevron.className = 'path-picker-item-chevron';
|
||||
chevron.textContent = '\u203A';
|
||||
open.appendChild(chevron);
|
||||
open.addEventListener('click', () => this.load(entry.path));
|
||||
} else if (!this._options.directoriesOnly) {
|
||||
open.addEventListener('click', () => this.select(entry.path));
|
||||
} else {
|
||||
open.disabled = true;
|
||||
}
|
||||
row.appendChild(open);
|
||||
|
||||
if (entry.type === 'directory' || !this._options.directoriesOnly) {
|
||||
const choose = document.createElement('button');
|
||||
choose.type = 'button';
|
||||
choose.className = 'path-picker-item-select';
|
||||
choose.textContent = 'Choose';
|
||||
choose.addEventListener('click', () => this.select(entry.path));
|
||||
row.appendChild(choose);
|
||||
}
|
||||
list.appendChild(row);
|
||||
}
|
||||
},
|
||||
|
||||
select(path) {
|
||||
if (!this.overlay) return;
|
||||
this._selectedPath = path;
|
||||
this.overlay.querySelector('.path-picker-selection-value').textContent = path;
|
||||
this.overlay.querySelector('.path-picker-confirm').disabled = false;
|
||||
this.overlay.querySelectorAll('.path-picker-item').forEach((row) => {
|
||||
const selected = row.dataset.path === path;
|
||||
row.classList.toggle('selected', selected);
|
||||
row.setAttribute('aria-selected', selected ? 'true' : 'false');
|
||||
});
|
||||
},
|
||||
|
||||
confirm() {
|
||||
if (!this._selectedPath || !this._options) return;
|
||||
const selectedPath = this._selectedPath;
|
||||
const onSelect = this._options.onSelect;
|
||||
this.close(false);
|
||||
onSelect(selectedPath);
|
||||
},
|
||||
|
||||
close(restoreFocus = true) {
|
||||
if (this._keydownHandler) document.removeEventListener('keydown', this._keydownHandler);
|
||||
this._keydownHandler = null;
|
||||
this._loadSequence += 1;
|
||||
this.overlay?.remove();
|
||||
this.overlay = null;
|
||||
const previousFocus = this._previousFocus;
|
||||
this._previousFocus = null;
|
||||
this._options = null;
|
||||
this._selectedPath = '';
|
||||
if (restoreFocus) previousFocus?.focus?.();
|
||||
},
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// Mobile Keyboard Accessory Bar
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -92,6 +316,8 @@ const KeyboardAccessoryBar = {
|
||||
<rect x="8" y="2" width="8" height="4" rx="1" ry="1"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button class="accessory-btn" data-action="pick-path" title="Insert a file or folder path">📁 Path</button>
|
||||
<button class="accessory-btn" data-action="clear-input" title="Clear the current unsent input">⌫ All</button>
|
||||
<button class="accessory-btn" data-action="tab" title="Tab">Tab</button>
|
||||
<button class="accessory-btn" data-action="shift-tab" title="Shift+Tab">⇧Tab</button>
|
||||
<button class="accessory-btn" data-action="effort-max" title="/effort max">Max</button>
|
||||
@@ -128,7 +354,7 @@ const KeyboardAccessoryBar = {
|
||||
this.handleAction(action, btn);
|
||||
|
||||
// Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts)
|
||||
const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl-o', 'opt-enter', 'esc', 'effort-max']);
|
||||
const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl-o', 'opt-enter', 'esc', 'effort-max', 'clear-input']);
|
||||
if (refocusActions.has(action) ||
|
||||
((action === 'clear' || action === 'compact') && this._confirmAction)) {
|
||||
if (typeof app !== 'undefined' && app.terminal) {
|
||||
@@ -207,6 +433,12 @@ const KeyboardAccessoryBar = {
|
||||
case 'paste':
|
||||
this.pasteFromClipboard();
|
||||
break;
|
||||
case 'pick-path':
|
||||
this.pickPath();
|
||||
break;
|
||||
case 'clear-input':
|
||||
app.clearTerminalInput?.();
|
||||
break;
|
||||
case 'dismiss':
|
||||
// Blur active element to dismiss keyboard
|
||||
document.activeElement?.blur();
|
||||
@@ -265,6 +497,22 @@ const KeyboardAccessoryBar = {
|
||||
}).catch(() => {});
|
||||
},
|
||||
|
||||
/** Browse the active session's workspace and insert a selected path without Enter. */
|
||||
pickPath() {
|
||||
if (!app.activeSessionId) return;
|
||||
const session = app.sessions?.get(app.activeSessionId);
|
||||
PathPicker.open({
|
||||
title: 'Insert File or Folder Path',
|
||||
sessionId: app.activeSessionId,
|
||||
initialPath: session?.workingDir || '',
|
||||
directoriesOnly: false,
|
||||
onSelect: (path) => {
|
||||
app.insertTerminalText?.(path);
|
||||
setTimeout(() => app.terminal?.focus(), 100);
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
/** Show a paste overlay for iOS compatibility.
|
||||
* Handles three input paths from one dialog:
|
||||
* - Text: long-press the textarea → Paste → Send (unchanged).
|
||||
|
||||
@@ -1863,6 +1863,25 @@ Object.assign(CodemanApp.prototype, {
|
||||
}
|
||||
},
|
||||
|
||||
openLinkCasePathPicker() {
|
||||
const pathInput = document.getElementById('linkCasePath');
|
||||
PathPicker.open({
|
||||
title: 'Select Existing Project Folder',
|
||||
initialPath: pathInput.value.trim(),
|
||||
directoriesOnly: true,
|
||||
onSelect: (path) => {
|
||||
pathInput.value = path;
|
||||
const nameInput = document.getElementById('linkCaseName');
|
||||
if (!nameInput.value.trim()) {
|
||||
const folderName = path.split('/').filter(Boolean).pop() || '';
|
||||
if (/^[\p{L}\p{N}_-]+$/u.test(folderName)) nameInput.value = folderName;
|
||||
}
|
||||
pathInput.focus();
|
||||
pathInput.setSelectionRange(path.length, path.length);
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
async linkRemoteCase() {
|
||||
const name = document.getElementById('remoteCaseName').value.trim();
|
||||
const remotePath = document.getElementById('remoteCasePath').value.trim();
|
||||
|
||||
@@ -10845,6 +10845,283 @@ body.touch-device.cjk-input-visible .main {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Shared lazy filesystem path picker (case linking + mobile input). */
|
||||
.path-input-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.path-input-group input {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.path-input-browse {
|
||||
flex: 0 0 auto;
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
.path-picker-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10020;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 16px;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.path-picker-dialog {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(680px, 100%);
|
||||
height: min(720px, calc(100dvh - 32px));
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.path-picker-header,
|
||||
.path-picker-nav,
|
||||
.path-picker-roots-row,
|
||||
.path-picker-selection,
|
||||
.path-picker-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.path-picker-header {
|
||||
justify-content: space-between;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.path-picker-title {
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.path-picker-close {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.5rem;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.path-picker-roots-row {
|
||||
padding: 10px 12px 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.path-picker-roots {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 8px 10px;
|
||||
color: var(--text);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.path-picker-nav {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.path-picker-up,
|
||||
.path-picker-refresh {
|
||||
flex: 0 0 38px;
|
||||
height: 38px;
|
||||
color: var(--text);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.path-picker-up:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.path-picker-current {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 9px 11px;
|
||||
overflow-x: auto;
|
||||
color: var(--accent);
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.path-picker-status {
|
||||
padding: 0 14px 8px;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.path-picker-status.error {
|
||||
color: var(--danger, #ef4444);
|
||||
}
|
||||
|
||||
.path-picker-list {
|
||||
flex: 1;
|
||||
min-height: 140px;
|
||||
overflow-y: auto;
|
||||
padding: 0 10px 10px;
|
||||
}
|
||||
|
||||
.path-picker-item {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
margin: 2px 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.path-picker-item:hover,
|
||||
.path-picker-item.selected {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.path-picker-item.selected {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.path-picker-item.not-selectable {
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.path-picker-item-main {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
padding: 10px 8px;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.path-picker-item-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 0.8rem;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.path-picker-item-icon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.path-picker-item-link,
|
||||
.path-picker-item-chevron {
|
||||
margin-left: 8px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.path-picker-item-select {
|
||||
align-self: center;
|
||||
margin-right: 6px;
|
||||
padding: 6px 9px;
|
||||
color: var(--accent);
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 7px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.path-picker-selection {
|
||||
padding: 9px 14px;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.72rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.path-picker-selection-value {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
font-family: var(--font-mono, monospace);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.path-picker-actions {
|
||||
padding: 10px 12px 12px;
|
||||
}
|
||||
|
||||
.path-picker-actions button {
|
||||
min-height: 40px;
|
||||
padding: 8px 13px;
|
||||
color: var(--text);
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.path-picker-action-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.path-picker-actions .path-picker-confirm {
|
||||
color: #fff;
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.path-picker-actions .path-picker-confirm:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.path-picker-overlay {
|
||||
align-items: flex-end;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.path-picker-dialog {
|
||||
width: 100%;
|
||||
height: min(82dvh, 720px);
|
||||
border-right: 0;
|
||||
border-bottom: 0;
|
||||
border-left: 0;
|
||||
border-radius: 16px 16px 0 0;
|
||||
padding-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
.path-picker-actions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.path-picker-current-select {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════════════════════════
|
||||
Orchestrator Panel
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
@@ -2418,6 +2418,50 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.terminal.clear();
|
||||
},
|
||||
|
||||
/** Insert editable text at the active prompt without pressing Enter. */
|
||||
insertTerminalText(text) {
|
||||
if (!this.activeSessionId || !text) return;
|
||||
if (this._localEchoEnabled && this._localEchoOverlay) {
|
||||
this._localEchoOverlay.appendText(text);
|
||||
} else {
|
||||
this.sendInput(text).catch(() => {});
|
||||
}
|
||||
this.terminal?.focus();
|
||||
},
|
||||
|
||||
/**
|
||||
* Clear only the current editable prompt. This is intentionally distinct
|
||||
* from Ctrl+L (clear display) and the agent's destructive `/clear` command.
|
||||
*/
|
||||
clearTerminalInput() {
|
||||
if (!this.activeSessionId) return;
|
||||
|
||||
if (typeof CjkInput !== 'undefined') CjkInput.clear();
|
||||
if (this._inputFlushTimeout) {
|
||||
clearTimeout(this._inputFlushTimeout);
|
||||
this._inputFlushTimeout = null;
|
||||
}
|
||||
this._pendingInput = '';
|
||||
|
||||
if (this._localEchoEnabled && this._localEchoOverlay) {
|
||||
const flushed = this._localEchoOverlay.getFlushed?.() || { count: 0, text: '' };
|
||||
this._localEchoOverlay.clear();
|
||||
this._localEchoOverlay.suppressBufferDetection();
|
||||
this._flushedOffsets?.delete(this.activeSessionId);
|
||||
this._flushedTexts?.delete(this.activeSessionId);
|
||||
if (flushed.count > 0) {
|
||||
this.sendInput('\x7f'.repeat(flushed.count)).catch(() => {});
|
||||
}
|
||||
} else {
|
||||
// In non-local-echo mode the TUI already owns the editable buffer. Ctrl+U
|
||||
// is the conventional kill-line key supported by shells and agent TUIs.
|
||||
this.sendInput('\x15').catch(() => {});
|
||||
}
|
||||
|
||||
this.showToast?.('Input cleared', 'success');
|
||||
this.terminal?.focus();
|
||||
},
|
||||
|
||||
/**
|
||||
* Restore terminal size to match web UI dimensions.
|
||||
* Use this after mobile screen attachment has squeezed the terminal.
|
||||
|
||||
@@ -4,9 +4,11 @@
|
||||
*/
|
||||
|
||||
import { FastifyInstance, type FastifyReply } from 'fastify';
|
||||
import { basename as pathBasename, join } from 'node:path';
|
||||
import { basename as pathBasename, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
||||
import { createReadStream, realpathSync, type ReadStream } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { homedir } from 'node:os';
|
||||
import type { ApiResponse, FilesystemBrowseData, FilesystemBrowseEntry, FilesystemBrowseRoot } from '../../types.js';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
|
||||
import { fileStreamManager } from '../../file-stream-manager.js';
|
||||
import {
|
||||
@@ -22,12 +24,20 @@ import { generateFirstPageThumbnail } from '../../document-thumbnailer.js';
|
||||
import { getOfficePreviewPdfPath, getPreviewPdfDownloadName } from '../../document-preview-cache.js';
|
||||
import { sanitizeAttachmentHistoryItem } from '../../session-attachment-history.js';
|
||||
import { isBlockedAttachmentPath, loadAttachmentGuardConfig } from '../../config/attachment-guard.js';
|
||||
import { canAccessOwned, findSessionOrFail, getAuthUser, validateSessionFilePath } from '../route-helpers.js';
|
||||
import {
|
||||
CASES_DIR,
|
||||
canAccessOwned,
|
||||
findSessionOrFail,
|
||||
getAuthUser,
|
||||
parseBody,
|
||||
validateSessionFilePath,
|
||||
} from '../route-helpers.js';
|
||||
import type { FastifyRequest } from 'fastify';
|
||||
import type { SessionAttachmentHistoryItem, SessionState } from '../../types/session.js';
|
||||
import { isSensitivePath } from '../sensitive-path.js';
|
||||
import { SseEvent } from '../sse-events.js';
|
||||
import type { ConfigPort, EventPort, SessionPort } from '../ports/index.js';
|
||||
import { FilesystemBrowseQuerySchema } from '../schemas.js';
|
||||
|
||||
const MIME_TYPES: Record<string, string> = {
|
||||
png: 'image/png',
|
||||
@@ -260,6 +270,75 @@ type AttachmentHistoryRouteItem = Omit<SessionAttachmentHistoryItem, 'externalPa
|
||||
attachmentId?: string;
|
||||
};
|
||||
|
||||
const FILESYSTEM_PICKER_ENTRY_LIMIT = 500;
|
||||
|
||||
function isPathWithinRoot(root: string, candidate: string): boolean {
|
||||
const rel = relative(root, candidate);
|
||||
return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`));
|
||||
}
|
||||
|
||||
function isBlockedPickerPath(path: string, blockedTrees: readonly string[], directory = false): boolean {
|
||||
if (isBlockedAttachmentPath(path, blockedTrees)) return true;
|
||||
// The shared sensitive-path matcher describes file locations such as
|
||||
// ~/.ssh/<key>. Probe a child path as well so the directory itself cannot be
|
||||
// opened and used to enumerate those filenames.
|
||||
return directory && isBlockedAttachmentPath(join(path, '__codeman_path_picker_probe__'), blockedTrees);
|
||||
}
|
||||
|
||||
function configuredFilesystemPickerRoots(): Array<{ label: string; path: string }> {
|
||||
const candidates: Array<{ label: string; path: string }> = [
|
||||
{ label: 'Home', path: homedir() },
|
||||
{ label: 'Codeman Cases', path: CASES_DIR },
|
||||
{ label: 'WSL D:', path: '/mnt/d' },
|
||||
];
|
||||
const extraRoots = process.env.CODEMAN_FILE_PICKER_ROOTS;
|
||||
if (extraRoots) {
|
||||
for (const [index, path] of extraRoots
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean)
|
||||
.entries()) {
|
||||
candidates.push({ label: `Configured ${index + 1}`, path });
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
async function resolveFilesystemPickerRoots(
|
||||
ctx: SessionPort & ConfigPort,
|
||||
sessionId?: string
|
||||
): Promise<FilesystemBrowseRoot[]> {
|
||||
const candidates = configuredFilesystemPickerRoots();
|
||||
if (sessionId) {
|
||||
const session = ctx.sessions.get(sessionId) ?? ctx.store.getSession(sessionId);
|
||||
if (!session) {
|
||||
throw Object.assign(new Error(`Session ${sessionId} not found`), {
|
||||
statusCode: 404,
|
||||
body: createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${sessionId} not found`),
|
||||
});
|
||||
}
|
||||
candidates.unshift({ label: 'Current Folder', path: session.workingDir });
|
||||
}
|
||||
|
||||
const guard = await loadAttachmentGuardConfig();
|
||||
const roots: FilesystemBrowseRoot[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const candidate of candidates) {
|
||||
if (!isAbsolute(candidate.path)) continue;
|
||||
try {
|
||||
const resolved = realpathSync(candidate.path);
|
||||
if (seen.has(resolved) || isBlockedPickerPath(resolved, guard.blockedTrees, true)) continue;
|
||||
const stat = await fs.stat(resolved);
|
||||
if (!stat.isDirectory()) continue;
|
||||
seen.add(resolved);
|
||||
roots.push({ label: candidate.label, path: resolved });
|
||||
} catch {
|
||||
// Optional roots (for example /mnt/d on non-WSL hosts) are omitted.
|
||||
}
|
||||
}
|
||||
return roots;
|
||||
}
|
||||
|
||||
function appendDownloadFlag(url: string): string {
|
||||
return `${url}${url.includes('?') ? '&' : '?'}download=true`;
|
||||
}
|
||||
@@ -375,6 +454,141 @@ async function buildExternalAttachmentRouteItem(
|
||||
}
|
||||
|
||||
export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & ConfigPort): void {
|
||||
// Lazy filesystem listing for the Link Existing and mobile input path pickers.
|
||||
app.get('/api/filesystem/browse', async (req, reply): Promise<ApiResponse<FilesystemBrowseData>> => {
|
||||
const { path: requestedPath, sessionId } = parseBody(FilesystemBrowseQuerySchema, req.query);
|
||||
const roots = await resolveFilesystemPickerRoots(ctx, sessionId);
|
||||
if (roots.length === 0) {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No filesystem browse roots are available');
|
||||
}
|
||||
|
||||
const fallbackRoot =
|
||||
roots.find((root) => root.label === 'Current Folder') ?? roots.find((root) => root.path === '/mnt/d') ?? roots[0];
|
||||
const candidatePath = resolve(requestedPath ?? fallbackRoot.path);
|
||||
|
||||
let resolvedPath: string;
|
||||
try {
|
||||
resolvedPath = realpathSync(candidatePath);
|
||||
} catch {
|
||||
reply.code(404);
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Folder not found: ${candidatePath}`);
|
||||
}
|
||||
|
||||
const matchingRoots = roots
|
||||
.filter((root) => isPathWithinRoot(root.path, resolvedPath))
|
||||
.sort((a, b) => b.path.length - a.path.length);
|
||||
const matchingRoot = matchingRoots[0];
|
||||
if (!matchingRoot) {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path is outside the allowed browse roots');
|
||||
}
|
||||
|
||||
const guard = await loadAttachmentGuardConfig();
|
||||
if (isBlockedPickerPath(resolvedPath, guard.blockedTrees, true)) {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Access to this folder is blocked');
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(resolvedPath);
|
||||
if (!stat.isDirectory()) {
|
||||
reply.code(400);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'The browse path must be a directory');
|
||||
}
|
||||
} catch {
|
||||
reply.code(404);
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Folder not found: ${candidatePath}`);
|
||||
}
|
||||
|
||||
let dirEntries;
|
||||
try {
|
||||
dirEntries = await fs.readdir(resolvedPath, { withFileTypes: true });
|
||||
} catch {
|
||||
reply.code(403);
|
||||
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'This folder cannot be read');
|
||||
}
|
||||
|
||||
dirEntries.sort((a, b) => {
|
||||
if (a.isDirectory() && !b.isDirectory()) return -1;
|
||||
if (!a.isDirectory() && b.isDirectory()) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
const entries: FilesystemBrowseEntry[] = [];
|
||||
let truncated = false;
|
||||
for (const entry of dirEntries) {
|
||||
if (entry.name.startsWith('.')) continue;
|
||||
if (entries.length >= FILESYSTEM_PICKER_ENTRY_LIMIT) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
|
||||
const visiblePath = join(candidatePath, entry.name);
|
||||
let targetPath: string;
|
||||
try {
|
||||
targetPath = realpathSync(visiblePath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetRoot = roots.some((root) => isPathWithinRoot(root.path, targetPath));
|
||||
if (!targetRoot) continue;
|
||||
|
||||
let type: FilesystemBrowseEntry['type'];
|
||||
let size: number | undefined;
|
||||
const symlink = entry.isSymbolicLink();
|
||||
if (entry.isDirectory()) {
|
||||
type = 'directory';
|
||||
} else if (entry.isFile()) {
|
||||
type = 'file';
|
||||
} else if (symlink) {
|
||||
try {
|
||||
const targetStat = await fs.stat(targetPath);
|
||||
type = targetStat.isDirectory() ? 'directory' : 'file';
|
||||
if (type === 'file') size = targetStat.size;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isBlockedPickerPath(targetPath, guard.blockedTrees, type === 'directory')) continue;
|
||||
if (type === 'file' && size === undefined) {
|
||||
try {
|
||||
size = (await fs.stat(targetPath)).size;
|
||||
} catch {
|
||||
// The path is still selectable even when a size lookup races a change.
|
||||
}
|
||||
}
|
||||
entries.push({ name: entry.name, path: visiblePath, type, size, symlink: symlink || undefined });
|
||||
}
|
||||
|
||||
const parentCandidate = resolve(candidatePath, '..');
|
||||
let parent: string | null = null;
|
||||
if (candidatePath !== matchingRoot.path) {
|
||||
try {
|
||||
const resolvedParent = realpathSync(parentCandidate);
|
||||
if (isPathWithinRoot(matchingRoot.path, resolvedParent)) parent = parentCandidate;
|
||||
} catch {
|
||||
// A concurrently removed parent simply disables upward navigation.
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
path: candidatePath,
|
||||
parent,
|
||||
root: matchingRoot.path,
|
||||
roots,
|
||||
entries,
|
||||
truncated,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// File tree listing
|
||||
app.get('/api/sessions/:id/files', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
|
||||
@@ -49,6 +49,29 @@ const safePathSchema = z.string().max(1000).refine(isValidWorkingDir, {
|
||||
message: 'Invalid path: must be absolute, no shell metacharacters or traversal',
|
||||
});
|
||||
|
||||
/**
|
||||
* Filesystem picker paths are never interpolated into a shell command, so legal
|
||||
* filename characters such as spaces, quotes, and parentheses are accepted.
|
||||
* Containment and symlink resolution are enforced by the route after parsing.
|
||||
*/
|
||||
const filesystemPickerPathSchema = z
|
||||
.string()
|
||||
.max(4096)
|
||||
.refine((p) => p.startsWith('/') && !p.includes('\0') && !p.includes('\n') && !p.includes('\r'), {
|
||||
message: 'Path must be an absolute filesystem path',
|
||||
})
|
||||
.refine((p) => !p.split('/').includes('..'), { message: 'Path traversal is not allowed' });
|
||||
|
||||
/** Query validation for the lazy, allowlisted filesystem path picker. */
|
||||
export const FilesystemBrowseQuerySchema = z.object({
|
||||
path: filesystemPickerPathSchema.optional(),
|
||||
sessionId: z
|
||||
.string()
|
||||
.max(100)
|
||||
.regex(/^[a-zA-Z0-9_-]+$/, 'Invalid session id')
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// ========== Env Var Allowlist ==========
|
||||
|
||||
/** Allowlisted env var key prefixes */
|
||||
|
||||
Reference in New Issue
Block a user