Skip to content

Commit 6dbd047

Browse files
branoverclaude
andauthored
feat: add a web-UI button to import a directory as a target (#281)
* feat: add a web-UI button to import a directory as a target Adds a secondary "Import dir" button beside the existing file-upload Add button in the Targets pane, opening a small modal that takes a host directory path (+ optional name, recon toggle) and calls the POST /api/projects/{id}/targets/dir endpoint added in #279. A typed path, not a file/folder picker: the server is loopback-only and self-hosted, so it already has the same host filesystem access the CLI (hexgraph ingest <dir>) and MCP tool (target_ingest_dir) trust — a browser can't hand back an absolute host path or upload an entire mounted tree. Verified end to end with a live Playwright run against a real seeded project: button renders correctly, modal opens/validates/errors cleanly, and importing a real host directory registers a new firmware_image-kind target with its hidden ELF child, visible immediately in the sidebar tree and graph. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: wrap Targets pane header actions so the new button doesn't clip collapse The new "Import dir" button pushed the pinned collapse-chevron control past the Targets pane's `overflow: hidden` boundary at the default sidebar width (268px) — the button became completely inaccessible via click, not just visually cramped. Confirmed via headless-Chromium bounding-box measurement: with the button, .pane-collapse sat at left=286/right=313 against a pane right edge of 280 (fully clipped); removing it, .pane-collapse fit at right=266. Wrap the action-button cluster (Add / Import dir / Ghidra) in a new .pane-actions flex container so it wraps to a second line under pressure instead of overflowing — the same treatment the right-pane header already gives .rp-tabs, documented in theme.css, for exactly this class of bug. Verified the fix restores an unclipped, clickable collapse button at both the default (268px) and minimum (180px) sidebar widths, and that clicking it still collapses the panel. Found during the PR #281 merge-gate review. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 790ab2c commit 6dbd047

4 files changed

Lines changed: 92 additions & 6 deletions

File tree

frontend/src/api.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,12 @@ export const api = {
415415
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail || `${r.status}`);
416416
return r.json();
417417
},
418+
// Import an already-extracted/mounted filesystem DIRECTORY as a target — a host path,
419+
// not a file upload (a browser can't practically upload an entire mounted tree; the
420+
// server is loopback-only and already has the same host filesystem access the CLI/MCP
421+
// tools do). `path` must be readable from wherever `hexgraph serve` is running.
422+
addTargetDir: (pid: string, path: string, name?: string, recon = true) =>
423+
postJSON<any>(`/api/projects/${pid}/targets/dir`, { path, name, recon }),
418424
};
419425

420426
export const SEV_ORDER = ["critical", "high", "medium", "low", "info"];
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { useState } from "react";
2+
import { api } from "../api";
3+
import { Icon } from "./Icon";
4+
5+
// Import an already-extracted/mounted filesystem DIRECTORY as a target — the secondary
6+
// path alongside the file-upload "Add" button, for when there's no packed firmware blob
7+
// to unpack, just a rootfs already on disk. A typed host path, not a file/folder picker:
8+
// the server is loopback-only/self-hosted and reads directly off its own filesystem
9+
// (same trust model as `hexgraph ingest <path>` / the target_ingest_dir MCP tool) — a
10+
// browser file input can't hand back an absolute host path or upload an entire tree.
11+
export default function ImportDirModal({ projectId, onClose, onDone }: {
12+
projectId: string; onClose: () => void; onDone: () => void;
13+
}) {
14+
const [path, setPath] = useState("");
15+
const [name, setName] = useState("");
16+
const [recon, setRecon] = useState(true);
17+
const [err, setErr] = useState("");
18+
const [busy, setBusy] = useState(false);
19+
20+
const doImport = async () => {
21+
if (!path.trim()) return;
22+
setBusy(true); setErr("");
23+
try {
24+
await api.addTargetDir(projectId, path.trim(), name.trim() || undefined, recon);
25+
onDone(); onClose();
26+
} catch (e: any) { setErr(String(e.message || e)); }
27+
finally { setBusy(false); }
28+
};
29+
30+
return (
31+
<div className="modal-backdrop" onMouseDown={(e) => { if (e.target === e.currentTarget) onClose(); }}>
32+
<div className="modal fade-in" style={{ width: 460 }}>
33+
<h3>
34+
<Icon name="chip" size={16} /> Import a directory
35+
<span style={{ flex: 1 }} />
36+
<button className="btn sm ghost icon" onClick={onClose}><Icon name="x" size={13} /></button>
37+
</h3>
38+
<p className="muted" style={{ fontSize: 11.5, marginTop: -4, marginBottom: 12 }}>
39+
Import an already-extracted or mounted filesystem (a rootfs) as a target, instead of
40+
uploading a packed firmware image. The path is read from the HexGraph server's own
41+
disk, so it must be reachable from wherever <code>hexgraph serve</code> is running.
42+
</p>
43+
{err && <div className="banner err" style={{ marginBottom: 10 }}>{err}</div>}
44+
<div className="field">
45+
<label>directory path</label>
46+
<input value={path} onChange={(e) => setPath(e.target.value)}
47+
placeholder="/path/to/mounted/rootfs" autoFocus
48+
onKeyDown={(e) => { if (e.key === "Enter" && path.trim() && !busy) doImport(); }} />
49+
</div>
50+
<div className="field">
51+
<label>name (optional)</label>
52+
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="defaults from the path" />
53+
</div>
54+
<label className="switch" style={{ fontSize: 12, display: "flex", gap: 8, alignItems: "center", marginTop: 4 }}>
55+
<input type="checkbox" checked={recon} onChange={(e) => setRecon(e.target.checked)} />
56+
<span>Recon each ELF found (needs Docker) — off registers the tree without analysis</span>
57+
</label>
58+
<div className="foot">
59+
<button className="btn ghost" onClick={onClose}>Cancel</button>
60+
<button className="btn primary" onClick={doImport} disabled={busy || !path.trim()}>
61+
<Icon name="plus" size={12} /> {busy ? "importing…" : "Import"}
62+
</button>
63+
</div>
64+
</div>
65+
</div>
66+
);
67+
}

frontend/src/pages/Workspace.tsx

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { AddNodeModal, AddEdgeModal } from "../components/Author";
2121
import ReportModal from "../components/ReportModal";
2222
import RunCompareModal from "../components/RunCompareModal";
2323
import GhidraImportModal from "../components/GhidraImportModal";
24+
import ImportDirModal from "../components/ImportDirModal";
2425
import SourceBrowser from "../components/SourceBrowser";
2526
import FunctionSourceViewer from "../components/FunctionSourceViewer";
2627
import { CampaignsPanel } from "../components/CampaignsPanel";
@@ -75,7 +76,7 @@ export default function Workspace() {
7576
const [fuzzFor, setFuzzFor] = useState<TargetNode | null>(null);
7677
const [q, setQ] = useState("");
7778
const [results, setResults] = useState<any | null>(null);
78-
const [modal, setModal] = useState<"node" | "edge" | "report" | "compare" | "ghidra" | "egress" | null>(null);
79+
const [modal, setModal] = useState<"node" | "edge" | "report" | "compare" | "ghidra" | "egress" | "dir" | null>(null);
7980
const [edgePrefill, setEdgePrefill] = useState<{ src: string; dst: string } | null>(null);
8081
const [ghidraBridge, setGhidraBridge] = useState(false);
8182
const [fuzzingEnabled, setFuzzingEnabled] = useState(false);
@@ -1062,12 +1063,19 @@ export default function Workspace() {
10621063
<div className="pane-h">
10631064
<Icon name="chip" size={14} /><span className="ttl">Targets</span>
10641065
<span className="grow" />
1065-
<button className="btn sm" onClick={() => fileRef.current?.click()}><Icon name="plus" size={12} /> Add</button>
1066-
{ghidraBridge && (
1067-
<button className="btn sm" title="Import a program open in Ghidra" onClick={() => setModal("ghidra")}>
1068-
<Icon name="bulb" size={12} /> Ghidra
1066+
{/* Wraps before pushing the pinned collapse control off a narrow pane — same
1067+
treatment as .rp-tabs on the right-pane header (theme.css). */}
1068+
<div className="pane-actions">
1069+
<button className="btn sm" onClick={() => fileRef.current?.click()}><Icon name="plus" size={12} /> Add</button>
1070+
<button className="btn sm" title="Import an already-extracted/mounted filesystem directory" onClick={() => setModal("dir")}>
1071+
<Icon name="folder" size={12} /> Import dir
10691072
</button>
1070-
)}
1073+
{ghidraBridge && (
1074+
<button className="btn sm" title="Import a program open in Ghidra" onClick={() => setModal("ghidra")}>
1075+
<Icon name="bulb" size={12} /> Ghidra
1076+
</button>
1077+
)}
1078+
</div>
10711079
<button className="btn sm icon ghost pane-collapse" title="Collapse Targets panel" onClick={toggleLeft}>
10721080
<span style={{ transform: "rotate(90deg)", display: "inline-flex" }}><Icon name="chevron" size={13} /></span>
10731081
</button>
@@ -1371,6 +1379,7 @@ export default function Workspace() {
13711379
{modal === "egress" && <EgressPanel projectId={projectId!} onClose={() => setModal(null)} />}
13721380
{modal === "compare" && <RunCompareModal targets={detail.targets} onClose={() => setModal(null)} />}
13731381
{modal === "ghidra" && <GhidraImportModal projectId={projectId!} onClose={() => setModal(null)} onDone={load} />}
1382+
{modal === "dir" && <ImportDirModal projectId={projectId!} onClose={() => setModal(null)} onDone={load} />}
13741383
{launchFor && (
13751384
<LaunchModal target={launchFor.target} taskType={launchFor.type} isMock={isMock}
13761385
initialObjective={launchFor.objective} initialParams={launchFor.params}

frontend/src/theme.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,10 @@ code { font-family: var(--mono); }
158158
/* the right-pane primary tab bar wraps before pushing the expand/collapse controls
159159
off a narrow pane (min width 280px) — they are primary nav, never hidden behind a scroll. */
160160
.rp-tabs { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; flex: 1 1 auto; min-width: 0; }
161+
/* Same treatment for a left-pane header's action-button cluster (e.g. Targets: Add /
162+
Import dir / Ghidra) — wraps to a second line before the pane's `overflow: hidden`
163+
clips a pinned control (the collapse chevron) off-screen entirely. */
164+
.pane-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: flex-end; min-width: 0; }
161165
.scroll { overflow: auto; padding: 10px; flex: 1; }
162166

163167
/* tree */

0 commit comments

Comments
 (0)