diff --git a/CHANGELOG.md b/CHANGELOG.md index a2de081..4c3f8aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Hermes Web UI -- Changelog +## [Unreleased] + +### Added +- **Workspace path autocomplete in Spaces** — the "Add workspace path" field in + the Spaces panel now suggests trusted directories as you type, supports + keyboard navigation plus `Tab` completion, and keeps hidden directories out of + the list unless the current path segment starts with `.`. Suggestions are + limited to trusted roots (home, saved workspaces, and the boot default + workspace subtree) and never enumerate blocked system roots. (`api/routes.py`, + `api/workspace.py`, `static/panels.js`, `static/style.css`) (partial for #616) + ## [v0.50.161] — 2026-04-23 ### Fixed diff --git a/api/routes.py b/api/routes.py index 5dad0a7..ca5c78e 100644 --- a/api/routes.py +++ b/api/routes.py @@ -300,6 +300,7 @@ from api.workspace import ( get_last_workspace, set_last_workspace, list_dir, + list_workspace_suggestions, read_file_content, safe_resolve_ws, resolve_trusted_workspace, @@ -711,6 +712,17 @@ def handle_get(handler, parsed) -> bool: handler, {"workspaces": load_workspaces(), "last": get_last_workspace()} ) + if parsed.path == "/api/workspaces/suggest": + qs = parse_qs(parsed.query) + prefix = qs.get("prefix", [""])[0] + return j( + handler, + { + "suggestions": list_workspace_suggestions(prefix), + "prefix": prefix, + }, + ) + if parsed.path == "/api/sessions/search": return _handle_sessions_search(handler, parsed) diff --git a/api/workspace.py b/api/workspace.py index dddae29..b66b42f 100644 --- a/api/workspace.py +++ b/api/workspace.py @@ -219,6 +219,141 @@ def set_last_workspace(path: str) -> None: logger.debug("Failed to set last workspace") +def _workspace_blocked_roots() -> tuple[Path, ...]: + return ( + # Linux / macOS + Path('/etc'), + Path('/usr'), + Path('/var'), + Path('/bin'), + Path('/sbin'), + Path('/boot'), + Path('/proc'), + Path('/sys'), + Path('/dev'), + Path('/lib'), + Path('/lib64'), + Path('/opt/homebrew'), + ) + + +def _is_within(path: Path, root: Path) -> bool: + try: + path.relative_to(root) + return True + except ValueError: + return False + + +def _trusted_workspace_roots() -> list[Path]: + roots: list[Path] = [] + + def add(candidate: str | Path | None) -> None: + if candidate in (None, ""): + return + try: + p = Path(candidate).expanduser().resolve() + except Exception: + return + if not p.exists() or not p.is_dir(): + return + if any(_is_within(p, blocked) for blocked in _workspace_blocked_roots()): + return + if p not in roots: + roots.append(p) + + add(Path.home()) + add(_BOOT_DEFAULT_WORKSPACE) + for w in load_workspaces(): + add(w.get("path")) + roots.sort(key=lambda p: len(str(p))) + return roots + + +def list_workspace_suggestions(prefix: str = "", limit: int = 12) -> list[str]: + """Return workspace path suggestions under trusted roots only. + + Suggestions are limited to directories under one of: + - Path.home() + - the boot default workspace + - already-saved workspace roots + + Arbitrary system prefixes return an empty list rather than an error so the + UI can safely autocomplete while the user types. + """ + roots = _trusted_workspace_roots() + if not roots: + return [] + + raw = (prefix or "").strip() + if not raw: + return [str(p) for p in roots[:limit]] + + if raw.startswith("~"): + target = Path(raw).expanduser() + elif Path(raw).is_absolute(): + target = Path(raw) + else: + target = Path.home() / raw + + normalized = str(target) + normalized_lower = normalized.lower() + suggestions: list[str] = [] + + def add(path: Path) -> None: + value = str(path) + if value not in suggestions: + suggestions.append(value) + + # If the user is typing a partial trusted root like /Users/xuef..., suggest + # the matching trusted roots without scanning arbitrary system parents. + for root in roots: + if str(root).lower().startswith(normalized_lower): + add(root) + + in_root = [ + root + for root in roots + if normalized == str(root) or normalized.startswith(str(root) + os.sep) + ] + if not in_root: + return suggestions[:limit] + + anchor_root = max(in_root, key=lambda p: len(str(p))) + ends_with_sep = raw.endswith(os.sep) or raw.endswith('/') + parent = target if ends_with_sep else target.parent + leaf = '' if ends_with_sep else target.name + show_hidden = leaf.startswith('.') + + try: + parent_resolved = parent.expanduser().resolve() + except Exception: + return suggestions[:limit] + + if not parent_resolved.exists() or not parent_resolved.is_dir(): + return suggestions[:limit] + if not _is_within(parent_resolved, anchor_root): + return suggestions[:limit] + + leaf_lower = leaf.lower() + try: + children = sorted(parent_resolved.iterdir(), key=lambda p: p.name.lower()) + except OSError: + return suggestions[:limit] + + for child in children: + if not child.is_dir(): + continue + if child.name.startswith('.') and not show_hidden: + continue + if leaf_lower and not child.name.lower().startswith(leaf_lower): + continue + add(child.resolve()) + if len(suggestions) >= limit: + break + return suggestions[:limit] + + def resolve_trusted_workspace(path: str | Path | None = None) -> Path: """Resolve and validate a workspace path. @@ -240,13 +375,6 @@ def resolve_trusted_workspace(path: str | Path | None = None) -> Path: None/empty path falls back to the boot-time DEFAULT_WORKSPACE, which is always trusted (it was validated at server startup). """ - _BLOCKED_SYSTEM_ROOTS = { - # Linux / macOS - Path('/etc'), Path('/usr'), Path('/var'), Path('/bin'), Path('/sbin'), - Path('/boot'), Path('/proc'), Path('/sys'), Path('/dev'), - Path('/lib'), Path('/lib64'), Path('/opt/homebrew'), - } - if path in (None, ""): return Path(_BOOT_DEFAULT_WORKSPACE).expanduser().resolve() @@ -258,7 +386,7 @@ def resolve_trusted_workspace(path: str | Path | None = None) -> Path: raise ValueError(f"Path is not a directory: {candidate}") # Block known system roots and their children - for blocked in _BLOCKED_SYSTEM_ROOTS: + for blocked in _workspace_blocked_roots(): try: candidate.relative_to(blocked) raise ValueError(f"Path points to a system directory: {candidate}") diff --git a/static/panels.js b/static/panels.js index 3f25cb4..0edd1a0 100644 --- a/static/panels.js +++ b/static/panels.js @@ -536,6 +536,94 @@ async function submitMemorySave() { // ── Workspace management ── let _workspaceList = []; // cached from /api/workspaces +let _wsSuggestTimer = null; +let _wsSuggestReq = 0; +let _wsSuggestIndex = -1; + +function closeWorkspacePathSuggestions(){ + const box=$('wsAddSuggestions'); + if(box){ + box.innerHTML=''; + box.style.display='none'; + } + _wsSuggestIndex=-1; +} + +function _applyWorkspaceSuggestion(path){ + const input=$('wsAddInput'); + const next=(path||'').endsWith('/')?(path||''):`${path||''}/`; + if(input){ + input.value=next; + input.focus(); + input.setSelectionRange(next.length, next.length); + } + scheduleWorkspacePathSuggestions(); +} + +function _highlightWorkspaceSuggestion(idx){ + const box=$('wsAddSuggestions'); + if(!box)return; + const items=[...box.querySelectorAll('.ws-suggest-item')]; + items.forEach((el,i)=>{ + const active=i===idx; + el.classList.toggle('active', active); + if(active) el.scrollIntoView({block:'nearest'}); + }); +} + +function _renderWorkspacePathSuggestions(paths){ + const box=$('wsAddSuggestions'); + if(!box)return; + box.innerHTML=''; + if(!paths || !paths.length){ + box.style.display='none'; + _wsSuggestIndex=-1; + return; + } + paths.forEach((path, idx)=>{ + const pathParts=(path||'').split('/').filter(Boolean); + const leaf=pathParts[pathParts.length-1]||path; + const parent=pathParts.length>1?`/${pathParts.slice(0,-1).join('/')}`:'/'; + const item=document.createElement('button'); + item.type='button'; + item.className='ws-suggest-item'; + item.innerHTML=`${esc(leaf)}${esc(parent)}`; + item.dataset.path=path; + item.onmouseenter=()=>{_wsSuggestIndex=idx;_highlightWorkspaceSuggestion(idx);}; + item.onmousedown=(e)=>{e.preventDefault();_applyWorkspaceSuggestion(path);}; + box.appendChild(item); + }); + box.style.display='block'; + _wsSuggestIndex=0; + _highlightWorkspaceSuggestion(_wsSuggestIndex); +} + +async function _loadWorkspacePathSuggestions(prefix){ + const reqId=++_wsSuggestReq; + try{ + const qs=new URLSearchParams({prefix:prefix||''}).toString(); + const data=await api(`/api/workspaces/suggest?${qs}`); + if(reqId!==_wsSuggestReq)return; + _renderWorkspacePathSuggestions(data.suggestions||[]); + }catch(_){ + if(reqId!==_wsSuggestReq)return; + closeWorkspacePathSuggestions(); + } +} + +function scheduleWorkspacePathSuggestions(){ + const input=$('wsAddInput'); + if(!input)return; + const prefix=input.value.trim(); + if(!prefix){ + closeWorkspacePathSuggestions(); + return; + } + if(_wsSuggestTimer) clearTimeout(_wsSuggestTimer); + _wsSuggestTimer=setTimeout(()=>{ + _loadWorkspacePathSuggestions(prefix); + }, 120); +} function getWorkspaceFriendlyName(path){ // Look up the friendly name from the workspace list cache, fallback to last path segment @@ -719,13 +807,70 @@ function renderWorkspacesPanel(workspaces){ } const addRow=document.createElement('div');addRow.className='ws-add-row'; addRow.innerHTML=` - +
+ +
`; panel.appendChild(addRow); + const suggestBox=document.createElement('div'); + suggestBox.id='wsAddSuggestions'; + suggestBox.className='ws-suggestions'; + suggestBox.style.display='none'; + panel.appendChild(suggestBox); const hint=document.createElement('div'); hint.style.cssText='font-size:11px;color:var(--muted);padding:4px 0 8px'; hint.textContent=t('workspace_paths_validated_hint'); panel.appendChild(hint); + const input=$('wsAddInput'); + if(input){ + input.oninput=()=>scheduleWorkspacePathSuggestions(); + input.onfocus=()=>{ + if(input.value.trim()) scheduleWorkspacePathSuggestions(); + else closeWorkspacePathSuggestions(); + }; + input.onkeydown=(e)=>{ + const box=$('wsAddSuggestions'); + const items=box?[...box.querySelectorAll('.ws-suggest-item')]:[]; + if(!items.length){ + if(e.key==='Enter'){ + e.preventDefault(); + addWorkspace(); + } + return; + } + if(e.key==='ArrowDown'){ + e.preventDefault(); + _wsSuggestIndex=Math.min(items.length-1,Math.max(-1,_wsSuggestIndex)+1); + _highlightWorkspaceSuggestion(_wsSuggestIndex); + return; + } + if(e.key==='ArrowUp'){ + e.preventDefault(); + _wsSuggestIndex=_wsSuggestIndex<=0?0:_wsSuggestIndex-1; + _highlightWorkspaceSuggestion(_wsSuggestIndex); + return; + } + if(e.key==='Escape'){ + e.preventDefault(); + closeWorkspacePathSuggestions(); + return; + } + if(e.key==='Enter'){ + e.preventDefault(); + if(_wsSuggestIndex>=0 && items[_wsSuggestIndex]){ + _applyWorkspaceSuggestion(items[_wsSuggestIndex].dataset.path||''); + }else{ + addWorkspace(); + } + return; + } + if(e.key==='Tab' && _wsSuggestIndex>=0 && items[_wsSuggestIndex]){ + e.preventDefault(); + _applyWorkspaceSuggestion(items[_wsSuggestIndex].dataset.path||''); + return; + } + }; + } } async function addWorkspace(){ @@ -737,10 +882,15 @@ async function addWorkspace(){ _workspaceList=data.workspaces; renderWorkspacesPanel(data.workspaces); if(input)input.value=''; + closeWorkspacePathSuggestions(); showToast(t('workspace_added')); }catch(e){setStatus(t('add_failed')+e.message);} } +document.addEventListener('click',e=>{ + if(!e.target.closest('.ws-add-input-wrap')) closeWorkspacePathSuggestions(); +}); + async function removeWorkspace(path){ const _rmWs=await showConfirmDialog({title:t('workspace_remove_confirm_title'),message:t('workspace_remove_confirm_message',path),confirmLabel:t('remove'),danger:true,focusCancel:true}); if(!_rmWs) return; diff --git a/static/style.css b/static/style.css index b11733b..d9158b6 100644 --- a/static/style.css +++ b/static/style.css @@ -874,6 +874,15 @@ .cmd-item-badge-skill{color:var(--accent-text);background:var(--accent-bg);border-color:var(--accent-bg-strong);} .ws-action-btn.danger:hover{background:rgba(239,83,80,.1);color:var(--error);border-color:var(--error);} .ws-add-row{display:flex;gap:8px;align-items:center;padding:10px 0 4px;} +.ws-add-input-wrap{flex:1;min-width:0;} +.ws-suggestions{margin:0 0 6px;background:var(--bg2);border:1px solid var(--border2);border-radius:8px;box-shadow:0 10px 24px rgba(0,0,0,.22);max-height:220px;overflow:auto;} +.ws-suggest-item{display:flex;flex-direction:column;gap:2px;width:100%;padding:8px 10px;border:0;background:transparent;color:var(--text);text-align:left;font-size:12px;cursor:pointer;} +.ws-suggest-item:hover{background:rgba(255,255,255,.08);} +.ws-suggest-item.active{background:var(--accent-bg);outline:1px solid var(--accent-bg-strong);outline-offset:-1px;box-shadow:inset 0 0 0 1px rgba(255,255,255,.04);} +.ws-suggest-leaf{font-size:13px;font-weight:600;color:var(--text);} +.ws-suggest-parent{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;} +.ws-suggest-item.active .ws-suggest-leaf{color:var(--accent-text);} +.ws-suggest-item.active .ws-suggest-parent{color:var(--text);} /* ── Message action buttons (copy, edit, retry) ── */ .msg-actions{display:flex;align-items:center;gap:2px;opacity:0;transition:opacity .15s;margin-left:auto;} .msg-row:hover .msg-actions{opacity:1;} diff --git a/tests/test_issue616.py b/tests/test_issue616.py new file mode 100644 index 0000000..b19d148 --- /dev/null +++ b/tests/test_issue616.py @@ -0,0 +1,17 @@ +import pathlib + + +def test_workspace_suggest_endpoint_is_wired(): + src = pathlib.Path("api/routes.py").read_text(encoding="utf-8") + assert '"/api/workspaces/suggest"' in src + + +def test_spaces_panel_uses_workspace_suggest_autocomplete(): + src = pathlib.Path("static/panels.js").read_text(encoding="utf-8") + assert "/api/workspaces/suggest" in src + assert "wsAddSuggestions" in src + assert "scheduleWorkspacePathSuggestions" in src + assert "if(!prefix)" in src + assert "dataset.path" in src + assert "scrollIntoView" in src + assert "_wsSuggestIndex=0" in src diff --git a/tests/test_sprint5.py b/tests/test_sprint5.py index 620f11c..dee6779 100644 --- a/tests/test_sprint5.py +++ b/tests/test_sprint5.py @@ -1,5 +1,5 @@ """Sprint 5 tests: workspace CRUD, file save, session index, JS serving.""" -import json, pathlib, uuid, urllib.request, urllib.error +import json, pathlib, uuid, urllib.request, urllib.error, urllib.parse import os from tests._pytest_port import BASE @@ -80,6 +80,34 @@ def test_workspace_add_requires_path(): result, status = post("/api/workspaces/add", {}) assert status == 400 +def test_workspace_suggest_returns_trusted_directories(cleanup_test_sessions): + _, ws = make_session_tracked(cleanup_test_sessions) + child = make_workspace_child(ws, f"workspace-suggest-{uuid.uuid4().hex[:6]}") + nested = make_workspace_child(child, "nested") + prefix = str(child.parent / child.name[:12]) + data, status = get(f"/api/workspaces/suggest?prefix={urllib.parse.quote(prefix)}") + assert status == 200 + assert str(child) in data["suggestions"] + assert all(not pathlib.Path(p).name.startswith('.') for p in data["suggestions"]) + +def test_workspace_suggest_hides_untrusted_system_prefix(): + data, status = get("/api/workspaces/suggest?prefix=/etc") + assert status == 200 + assert data["suggestions"] == [] + +def test_workspace_suggest_hidden_dirs_only_when_requested(cleanup_test_sessions): + _, ws = make_session_tracked(cleanup_test_sessions) + hidden = make_workspace_child(ws, ".workspace-hidden") + visible = make_workspace_child(ws, "workspace-visible") + base = str(ws) + "/" + data, status = get(f"/api/workspaces/suggest?prefix={urllib.parse.quote(base)}") + assert status == 200 + assert str(visible) in data["suggestions"] + assert str(hidden) not in data["suggestions"] + data2, status2 = get(f"/api/workspaces/suggest?prefix={urllib.parse.quote(base + '.w')}") + assert status2 == 200 + assert str(hidden) in data2["suggestions"] + def test_workspace_remove(cleanup_test_sessions): _, ws = make_session_tracked(cleanup_test_sessions) child = make_workspace_child(ws, f"workspace-remove-{uuid.uuid4().hex[:6]}")