Skip to content

Commit 72f10e2

Browse files
feat: filesystem directory picker for path fields (0.2.1)
- GET /api/fs/browse — server-side dir listing (dirs only, hides hidden except .attune/.help); breadcrumb nav + parent traversal - PathBrowser.jsx — modal with breadcrumb nav, folder list, footer showing current path, Cancel/Select buttons - PathField.jsx — wraps text input with Browse… button; inline red- border validation for relative paths (must start with / or ~) - FormPanel routes ui:widget=path fields to PathField - All project_path / project_root / help_dir / template_dir args now carry ui:widget=path so they render as pickers - CommandsPanel: friendly domain labels (Search & Query / Author Docs / Help Lookup) instead of raw domain keys - Bump version 0.2.0 → 0.2.1 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 0cf8b07 commit 72f10e2

11 files changed

Lines changed: 431 additions & 9 deletions

File tree

.help/features.yaml

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
version: 1
2+
features:
3+
attune_gui-entry:
4+
description: 'Entry point: sidecar/attune_gui/app.py'
5+
files:
6+
- sidecar/attune_gui/app.py
7+
tags:
8+
- entry-point
9+
scripts:
10+
description: Scripts
11+
files:
12+
- scripts/**
13+
sidecar:
14+
description: Sidecar
15+
files:
16+
- sidecar/**
17+
ui:
18+
description: Ui
19+
files:
20+
- ui/**

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "attune-gui"
7-
version = "0.2.0"
7+
version = "0.2.1"
88
description = "Local Living Docs dashboard for the attune-rag / attune-help / attune-author family. FastAPI sidecar + React UI."
99
readme = "README.md"
1010
requires-python = ">=3.10"

sidecar/attune_gui/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,4 @@
22

33
from __future__ import annotations
44

5-
__version__ = "0.1.0"
5+
__version__ = "0.2.1"

sidecar/attune_gui/app.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from fastapi.staticfiles import StaticFiles
1212

1313
from attune_gui import __version__
14-
from attune_gui.routes import help, jobs, living_docs, profile, rag, system # noqa: F401
14+
from attune_gui.routes import fs, help, jobs, living_docs, profile, rag, system # noqa: F401
1515
from attune_gui.security import origin_guard
1616

1717
logger = logging.getLogger(__name__)
@@ -38,6 +38,7 @@ def create_app() -> FastAPI:
3838
)
3939

4040
app.include_router(system.router)
41+
app.include_router(fs.router)
4142
app.include_router(rag.router)
4243
app.include_router(jobs.router)
4344
app.include_router(help.router)

sidecar/attune_gui/commands.py

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -226,11 +226,13 @@ async def _exec_author_generate(args: dict[str, Any], ctx: JobContext) -> dict[s
226226
"type": "string",
227227
"title": ".help/ path",
228228
"default": ".help",
229+
"ui:widget": "path",
229230
},
230231
"project_root": {
231232
"type": "string",
232233
"title": "Project root",
233234
"default": ".",
235+
"ui:widget": "path",
234236
},
235237
"all_kinds": {
236238
"type": "boolean",
@@ -334,6 +336,7 @@ async def _exec_author_init(args: dict[str, Any], ctx: JobContext) -> dict[str,
334336
"title": "Project root",
335337
"default": ".",
336338
"description": "Root of the project to scan.",
339+
"ui:widget": "path",
337340
},
338341
},
339342
},
@@ -379,16 +382,23 @@ async def _exec_author_status(args: dict[str, Any], ctx: JobContext) -> dict[str
379382
args_schema={
380383
"type": "object",
381384
"properties": {
382-
"project_path": {"type": "string", "title": "Project path", "default": ""},
385+
"project_path": {
386+
"type": "string",
387+
"title": "Project path",
388+
"default": "",
389+
"ui:widget": "path",
390+
},
383391
"help_dir": {
384392
"type": "string",
385393
"title": ".help/ path (overrides project_path)",
386394
"default": "",
395+
"ui:widget": "path",
387396
},
388397
"project_root": {
389398
"type": "string",
390399
"title": "Project root (overrides project_path)",
391400
"default": "",
401+
"ui:widget": "path",
392402
},
393403
},
394404
},
@@ -437,16 +447,23 @@ async def _exec_author_maintain(args: dict[str, Any], ctx: JobContext) -> dict[s
437447
args_schema={
438448
"type": "object",
439449
"properties": {
440-
"project_path": {"type": "string", "title": "Project path", "default": ""},
450+
"project_path": {
451+
"type": "string",
452+
"title": "Project path",
453+
"default": "",
454+
"ui:widget": "path",
455+
},
441456
"help_dir": {
442457
"type": "string",
443458
"title": ".help/ path (overrides project_path)",
444459
"default": "",
460+
"ui:widget": "path",
445461
},
446462
"project_root": {
447463
"type": "string",
448464
"title": "Project root (overrides project_path)",
449465
"default": "",
466+
"ui:widget": "path",
450467
},
451468
"features": {
452469
"type": "string",
@@ -521,7 +538,12 @@ async def _exec_author_lookup(args: dict[str, Any], ctx: JobContext) -> dict[str
521538
"default": "concept",
522539
"description": "concept | task | reference",
523540
},
524-
"help_dir": {"type": "string", "title": ".help/ path", "default": ".help"},
541+
"help_dir": {
542+
"type": "string",
543+
"title": ".help/ path",
544+
"default": ".help",
545+
"ui:widget": "path",
546+
},
525547
},
526548
"required": ["query"],
527549
},
@@ -531,8 +553,6 @@ async def _exec_author_lookup(args: dict[str, Any], ctx: JobContext) -> dict[str
531553
)
532554

533555

534-
535-
536556
# ---------------------------------------------------------------------------
537557
# Help: lookup
538558
# ---------------------------------------------------------------------------
@@ -604,6 +624,7 @@ async def _exec_help_lookup(args: dict[str, Any], ctx: JobContext) -> dict[str,
604624
"title": "Template dir",
605625
"default": "",
606626
"description": "Path to .help/templates/ directory. Leave blank to use bundled templates.",
627+
"ui:widget": "path",
607628
},
608629
},
609630
"required": ["topic"],
@@ -663,6 +684,7 @@ async def _exec_help_search(args: dict[str, Any], ctx: JobContext) -> dict[str,
663684
"title": "Template dir",
664685
"default": "",
665686
"description": "Path to .help/templates/ directory. Leave blank to use bundled templates.",
687+
"ui:widget": "path",
666688
},
667689
},
668690
"required": ["query"],
@@ -714,6 +736,7 @@ async def _exec_help_list(args: dict[str, Any], ctx: JobContext) -> dict[str, An
714736
"title": "Template dir",
715737
"default": "",
716738
"description": "Path to .help/templates/ directory. Leave blank to use bundled templates.",
739+
"ui:widget": "path",
717740
},
718741
},
719742
},

sidecar/attune_gui/routes/fs.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
"""Filesystem browsing — directories only, local use."""
2+
3+
from __future__ import annotations
4+
5+
from pathlib import Path
6+
7+
from fastapi import APIRouter, HTTPException, Query
8+
9+
router = APIRouter(prefix="/api/fs", tags=["fs"])
10+
11+
_SHOW_HIDDEN = {".help", ".attune"}
12+
13+
14+
@router.get("/browse")
15+
async def browse(path: str = Query(default="~")) -> dict:
16+
"""Return directory listing for *path*.
17+
18+
Only directories are included. Hidden entries (names starting with ``.'')
19+
are suppressed except for a small allow-list that is relevant to attune.
20+
"""
21+
try:
22+
resolved = Path(path).expanduser().resolve()
23+
except Exception as exc:
24+
raise HTTPException(status_code=400, detail=f"Invalid path: {exc}") from exc
25+
26+
if not resolved.is_dir():
27+
raise HTTPException(status_code=400, detail=f"Not a directory: {resolved}")
28+
29+
try:
30+
children = sorted(resolved.iterdir(), key=lambda p: p.name.lower())
31+
except PermissionError as exc:
32+
raise HTTPException(status_code=403, detail=str(exc)) from exc
33+
34+
entries = []
35+
for child in children:
36+
if not child.is_dir():
37+
continue
38+
name = child.name
39+
if name.startswith(".") and name not in _SHOW_HIDDEN:
40+
continue
41+
entries.append({"name": name, "path": str(child)})
42+
43+
parent = str(resolved.parent) if resolved.parent != resolved else None
44+
45+
return {
46+
"path": str(resolved),
47+
"parent": parent,
48+
"entries": entries,
49+
}

ui/src/components/CommandsPanel.jsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
const DOMAIN_LABELS = {
2+
rag: 'Search & Query',
3+
author: 'Author Docs',
4+
help: 'Help Lookup',
5+
}
6+
17
export default function CommandsPanel({ commands, selected, onSelect }) {
28
const byDomain = {}
39
for (const c of commands) {
@@ -10,7 +16,7 @@ export default function CommandsPanel({ commands, selected, onSelect }) {
1016
<div className="col-body">
1117
{Object.entries(byDomain).map(([domain, cmds]) => (
1218
<div key={domain} className="cmd-domain">
13-
<div className="cmd-domain-label">{domain}</div>
19+
<div className="cmd-domain-label">{DOMAIN_LABELS[domain] ?? domain}</div>
1420
{cmds.map(c => (
1521
<button
1622
key={c.name}

ui/src/components/FormPanel.jsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useState, useEffect } from 'react'
22
import { api } from '../api'
3+
import PathField from './PathField'
34

45
function buildDefaults(schema) {
56
const props = schema?.properties || {}
@@ -100,6 +101,19 @@ export default function FormPanel({ cmd, onJobStarted, bootError }) {
100101
</div>
101102
)
102103
}
104+
if (prop['ui:widget'] === 'path') {
105+
return (
106+
<PathField
107+
key={key}
108+
id={`f_${key}`}
109+
value={val}
110+
label={label}
111+
isRequired={isReq}
112+
description={desc}
113+
onChange={v => set(key, v)}
114+
/>
115+
)
116+
}
103117
if (prop['ui:widget'] === 'textarea') {
104118
return (
105119
<div key={key} className="field">

ui/src/components/PathBrowser.jsx

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { useState, useEffect, useCallback } from 'react'
2+
import { api } from '../api'
3+
4+
export default function PathBrowser({ initialPath, onSelect, onClose }) {
5+
const [data, setData] = useState(null)
6+
const [loading, setLoading] = useState(false)
7+
const [error, setError] = useState(null)
8+
9+
const load = useCallback(async (path) => {
10+
setLoading(true)
11+
setError(null)
12+
try {
13+
const result = await api(`/api/fs/browse?path=${encodeURIComponent(path)}`)
14+
setData(result)
15+
} catch (err) {
16+
setError(err.message)
17+
} finally {
18+
setLoading(false)
19+
}
20+
}, [])
21+
22+
useEffect(() => {
23+
load(initialPath || '~')
24+
}, []) // eslint-disable-line react-hooks/exhaustive-deps
25+
26+
const breadcrumbs = data ? buildCrumbs(data.path) : []
27+
28+
function handleKey(e) {
29+
if (e.key === 'Escape') onClose()
30+
}
31+
32+
return (
33+
<div className="pb-backdrop" onClick={onClose} onKeyDown={handleKey}>
34+
<div className="pb-modal" onClick={e => e.stopPropagation()} role="dialog" aria-modal="true">
35+
<div className="pb-header">
36+
<span className="pb-title">Select directory</span>
37+
<button className="pb-close" onClick={onClose} aria-label="Close"></button>
38+
</div>
39+
40+
<div className="pb-crumbs">
41+
{breadcrumbs.map((crumb, i) => (
42+
<span key={crumb.path} className="pb-crumb">
43+
{i > 0 && <span className="pb-sep">/</span>}
44+
<button className="pb-crumb-btn" onClick={() => load(crumb.path)}>
45+
{crumb.label || '/'}
46+
</button>
47+
</span>
48+
))}
49+
</div>
50+
51+
<div className="pb-list">
52+
{loading && <div className="pb-status">Loading…</div>}
53+
{error && <div className="pb-status pb-error">{error}</div>}
54+
{!loading && !error && data && (
55+
<>
56+
{data.parent && (
57+
<button className="pb-entry pb-up" onClick={() => load(data.parent)}>
58+
↑ ..
59+
</button>
60+
)}
61+
{data.entries.length === 0 && (
62+
<div className="pb-status">No subdirectories</div>
63+
)}
64+
{data.entries.map(entry => (
65+
<button
66+
key={entry.path}
67+
className="pb-entry"
68+
onClick={() => load(entry.path)}
69+
>
70+
<span className="pb-icon">📁</span>
71+
<span className="pb-entry-name">{entry.name}</span>
72+
</button>
73+
))}
74+
</>
75+
)}
76+
</div>
77+
78+
<div className="pb-footer">
79+
{data && (
80+
<span className="pb-current-path">{data.path}</span>
81+
)}
82+
<div className="pb-actions">
83+
<button className="ghost" onClick={onClose}>Cancel</button>
84+
<button
85+
className="primary"
86+
disabled={!data}
87+
onClick={() => { if (data) onSelect(data.path) }}
88+
>
89+
Select
90+
</button>
91+
</div>
92+
</div>
93+
</div>
94+
</div>
95+
)
96+
}
97+
98+
function buildCrumbs(absPath) {
99+
const parts = absPath.split('/').filter(Boolean)
100+
const crumbs = [{ label: '', path: '/' }]
101+
for (let i = 0; i < parts.length; i++) {
102+
crumbs.push({
103+
label: parts[i],
104+
path: '/' + parts.slice(0, i + 1).join('/'),
105+
})
106+
}
107+
return crumbs
108+
}

0 commit comments

Comments
 (0)