diff --git a/.gitignore b/.gitignore index 49e8c0d..0ae0581 100644 --- a/.gitignore +++ b/.gitignore @@ -49,11 +49,3 @@ __pycache__/ **/*.snap !crates/sephera_cli/tests/snapshots/*.snap .astro - -# A zero-byte artefact of running -px sass ... nul on Windows, where -ul is a -# reserved device name: the shell redirects to the device, but sass opens the -# path as a file and leaves a real 0-byte file behind. Windows then refuses to -# delete it through an ordinary path, so it is ignored rather than removed. -/gui/nul diff --git a/gui/src/App.tsx b/gui/src/App.tsx index fefa399..9dbfd5c 100644 --- a/gui/src/App.tsx +++ b/gui/src/App.tsx @@ -1,19 +1,18 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import * as ScrollArea from "@radix-ui/react-scroll-area"; -import * as Separator from "@radix-ui/react-separator"; import { FolderOpen, PanelRightClose, RefreshCw } from "lucide-react"; import { ActivityBar, PANELS } from "./components/ActivityBar"; import { FileTree } from "./components/FileTree"; import { StatusBar } from "./components/StatusBar"; import { TabBar } from "./components/TabBar"; +import { DependenciesView } from "./views/DependenciesView"; import { LocView } from "./views/LocView"; import { SymbolsView } from "./views/SymbolsView"; import { countLines } from "./lib/ipc"; import { open as openDialog } from "@tauri-apps/plugin-dialog"; import styles from "./styles/workbench.module.scss"; import editor from "./styles/editor.module.scss"; -import views from "./styles/views.module.scss"; /** The views the editor can show. */ const TABS = [ @@ -30,6 +29,7 @@ export function App() { const [panel, setPanel] = useState("explorer"); const [tab, setTab] = useState("loc"); const [selected, setSelected] = useState(null); + const [graphTarget, setGraphTarget] = useState(""); const [reloadToken, setReloadToken] = useState(0); const [summary, setSummary] = useState(null); const [config, setConfig] = useState(null); @@ -138,7 +138,14 @@ export function App() { {panel === "explorer" ? ( { + setSelected(picked); + // A file picked in the explorer is also the graph's target, so + // the view has something to show the moment a file is chosen. + // A directory in the tree is expanded, not opened, so only + // files reach here. + setGraphTarget(picked); + }} selected={selected} /> ) : ( @@ -202,7 +209,12 @@ export function App() { reloadToken={reloadToken} /> ) : ( - + )} @@ -217,19 +229,10 @@ export function App() { busy={false} onRecompute={reopen} /> - - {/* Radix's separator, used where the shell needs a rule rather than a - border: it is one element with a role, and `decorative` where the rule - is visual only. */} - ); } -/** The label for a tab id, without the caller having to know the table. */ -function labelFor(id: string): string { - return TABS.find((tab) => tab.id === id)?.label ?? id; -} /** The configuration panel: which file was read, and what it contributed. */ function ConfigPanel({ @@ -250,17 +253,3 @@ function ConfigPanel({ ); } -/** A view that is not built yet, in the shape the real ones use. */ -function NotBuilt({ label }: { label: string }) { - return ( -
- {label} - - The command and its types already exist in{" "} - gui/src-tauri/src/commands/graph.rs and{" "} - gui/src/lib/ipc.ts. Only the React is missing, and building - it is the next round. - -
- ); -} diff --git a/gui/src/lib/ipc.ts b/gui/src/lib/ipc.ts index 59f90ff..5ca06db 100644 --- a/gui/src/lib/ipc.ts +++ b/gui/src/lib/ipc.ts @@ -69,26 +69,62 @@ export interface TreeNode { children: TreeNode[]; } -/** A reverse-dependency query, as the graph command accepts it. */ +/** A file in the graph, with its degrees. */ +export interface GraphNode { + file_path: string; + /** `null` when the language could not be detected. */ + language: string | null; + /** How many imports this file makes. */ + imports_count: number; + /** How many files import this file -- the number `impact` reports. */ + imported_by_count: number; +} + +/** One import statement, resolved or not. */ +export interface GraphEdge { + from: string; + import_path: string; + /** `null` for an external dependency or a path that could not be placed. */ + to: string | null; + resolved: boolean; + /** + * True when the path looked local and could not be placed. Distinct from + * `!resolved`, which also covers every external crate: a gap is a resolver + * defect, an external import is not. + */ + local_gap: boolean; + /** Gated behind a `#[cfg(...)]`. */ + cfg_gated: boolean; + kind: string; +} + +/** A top-level count, as the report's metrics. */ +export interface GraphMetrics { + total_files: number; + total_internal_edges: number; + self_references: number; + total_external_edges: number; + unresolved_local_edges: number; + cfg_gated_edges: number; + circular_dependencies: number; +} + +/** + * A reverse-dependency report: everything that depends on one file. + * + * This is the view where a graphical client earns its keep. The CLI can print + * the number and stop there; it cannot let a reader click a dependent and then + * ask the same question about *that* file, which is how a blast radius is + * actually explored. + */ export interface GraphReport { base_path: string; focus_paths: string[]; depth: number | null; query: { DependsOn: string } | null; - nodes: Array<{ - file_path: string; - language: string; - is_test: boolean; - }>; - edges: Array<{ - from: string; - import_path: string; - to: string | null; - resolved: boolean; - local_gap: boolean; - kind: string; - }>; - metrics: Record; + nodes: GraphNode[]; + edges: GraphEdge[]; + metrics: GraphMetrics; } /** Count a directory. Extra patterns are merged after `.sephera.toml`'s. */ diff --git a/gui/src/styles/views.module.scss b/gui/src/styles/views.module.scss index 212955c..d58879b 100644 --- a/gui/src/styles/views.module.scss +++ b/gui/src/styles/views.module.scss @@ -86,7 +86,70 @@ color: t.$text-bright; } -// A row of controls under a view header, for a query that takes an argument. +// A row's whole content is the action, so it is styled as a link rather than as a +// button: it navigates. +.link { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 0; + border: 0; + background: transparent; + color: t.$link-foreground; + font: inherit; + text-align: left; + cursor: pointer; + + &:hover { + text-decoration: underline; + } +} + +// ---- walk summary -------------------------------------------------------- + +// A definition list rather than a row of cards: it reads as "label: value" and +// that is what the metrics are. Six cards with borders would be six boxes to look +// at instead of six numbers. +.summary { + display: flex; + flex-wrap: wrap; + gap: 18px; + margin: 0 0 16px; + padding: 8px 10px; + background: t.$side-bar-bg; + border-left: 3px solid t.$widget-border; + font-size: 12px; + + > div { + display: flex; + flex-direction: column; + gap: 1px; + } + + dt { + color: t.$text-muted; + text-transform: uppercase; + letter-spacing: 0.05em; + font-size: 10px; + } + + dd { + margin: 0; + color: t.$text-normal; + font-family: t.$font-mono; + font-variant-numeric: tabular-nums; + } +} + +// A number that qualifies the answer is coloured, because a reader scanning six +// figures should not have to remember which one was the caveat. +.summary__warn { + color: t.$warning-fg; + font-weight: 600; +} + +// ---- query row ----------------------------------------------------------- + .query { display: flex; align-items: center; @@ -110,4 +173,25 @@ border-color: t.$focus-border; } } + + button { + height: 24px; + padding: 0 10px; + border: 1px solid transparent; + border-radius: t.$radius-small; + background: t.$input-bg; + color: t.$text-normal; + font: inherit; + font-size: 12px; + cursor: pointer; + white-space: nowrap; + display: inline-flex; + align-items: center; + gap: 5px; + + &:hover { + background: t.$list-hover-bg; + border-color: t.$widget-border; + } + } } diff --git a/gui/src/views/DependenciesView.tsx b/gui/src/views/DependenciesView.tsx new file mode 100644 index 0000000..b8db21b --- /dev/null +++ b/gui/src/views/DependenciesView.tsx @@ -0,0 +1,288 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + AlertTriangle, + ArrowLeft, + Crosshair, + FileCode2, + Loader, +} from "lucide-react"; + +import { dependencyGraph, type GraphNode, type GraphReport } from "../lib/ipc"; +import styles from "../styles/views.module.scss"; + +/** + * The reverse-dependency view: what breaks if this file changes. + * + * This is the one view where a graphical client genuinely beats a terminal, and + * the reason is in the last two lines of the component. `sephera impact` answers + * for one file and stops. Here, every row is a button that makes *that* file the + * target, so a reader walks the blast radius outward instead of re-running a + * command per hop. + * + * The target's own `imported_by_count` is not what is shown. It is the count over + * the whole tree, which includes paths the reverse traversal never reaches -- so + * showing it would claim a radius larger than the one reported. The list length + * is the honest number, and the header says so. + */ +export function DependenciesView({ + path, + target, + onTargetChange, + reloadToken, +}: { + path: string; + /** The file to measure from, chosen in the sidebar or typed here. */ + target: string; + onTargetChange: (path: string) => void; + reloadToken: number; +}) { + const [query, setQuery] = useState(target); + const [report, setReport] = useState(null); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + /** The files the query returned, in the order they should be walked. */ + const [trail, setTrail] = useState([]); + + const run = useCallback( + async (file: string) => { + setBusy(true); + setError(null); + try { + setReport(await dependencyGraph(path, file, null, [])); + setTrail((previous) => + previous[previous.length - 1] === file ? previous : [...previous, file], + ); + } catch (cause) { + setError(String(cause)); + setReport(null); + } finally { + setBusy(false); + } + }, + [path], + ); + + // A new target from outside -- the sidebar's file tree, say -- replaces the + // trail rather than extending it. The trail is a walk backwards through + // dependents, and jumping somewhere else is a different walk. + useEffect(() => { + setQuery(target); + void run(target); + setTrail([target]); + }, [run, target, reloadToken]); + + // Dependents, sorted by how much they import: a file that reaches the target + // through twelve paths is a more interesting neighbour than one that reaches + // it through one. + const dependents = useMemo(() => { + if (!report) return []; + return report.nodes + .filter((node) => node.file_path !== target) + .sort((left, right) => right.imports_count - left.imports_count); + }, [report, target]); + + if (!target) { + return ( +
+ Pick a file + + Choose one in the explorer, or type a path and press{" "} + Enter. The graph is built for that + file and everything is rebuilt when the file changes. + +
+ ); + } + + return ( +
+
+

Dependencies

+

+ {report + ? `${dependents.length} file${dependents.length === 1 ? "" : "s"} depend on this` + : "building the graph"} +

+
+ +
+ setQuery(event.target.value)} + aria-label="File to trace" + placeholder="crates/sephera_graph/src/resolver.rs" + onKeyDown={(event) => { + if (event.key === "Enter" && query.trim()) { + onTargetChange(query.trim()); + } + }} + /> + + {trail.length > 1 ? ( + + ) : null} +
+ + {error ? ( +
+ + + {error} +
+ + The path has to exist inside the directory being analysed. A path + outside it, or one the resolver never placed, has no graph to show. + +
+
+ ) : busy && !report ? ( +
+ + + + The whole tree is parsed once, then traversed in reverse. On a + workspace this size it is well under a second; on a large repository + it is a few seconds. + +
+ ) : report ? ( + <> + + + + ) : null} +
+ ); +} + +/** The metrics that say whether the answer above can be trusted. */ +function WalkSummary({ + report, + trail, +}: { + report: GraphReport; + trail: readonly string[]; +}) { + const { metrics } = report; + + return ( +
+
+
Walk
+
+ {trail.map((file, index) => ( + + {index > 0 ? " → " : ""} + {name(file)} + + ))} +
+
+
+
Files
+
{metrics.total_files.toLocaleString()}
+
+
+
Internal edges
+
{metrics.total_internal_edges.toLocaleString()}
+
+ {/* Two numbers that qualify the answer, and the reason each is shown next + to it. A radius built while the resolver is dropping local paths is a + radius that understates, and a reader deserves to know which one they + are looking at. */} +
+
Unresolved local
+
0 ? styles.summary__warn : undefined} + > + {metrics.unresolved_local_edges.toLocaleString()} +
+
+
+
Cfg-gated
+
{metrics.cfg_gated_edges.toLocaleString()}
+
+
+ ); +} + +/** The dependents, each clickable to become the next target. */ +function DependentList({ + dependents, + target, + onSelect, +}: { + dependents: readonly GraphNode[]; + target: string; + onSelect: (path: string) => void; +}) { + if (dependents.length === 0) { + return ( +

+ Nothing in this tree imports {target}. +

+ ); + } + + return ( + + + + + + + + + + + {dependents.map((node) => ( + + {/* The whole row is a button, so the click target is the file name + rather than a small chevron at the right of it. */} + + + + + ))} + +
Files that depend on the target
FileLanguage + Imports +
+ + {node.language ?? "—"}{node.imports_count.toLocaleString()}
+ ); +} + +/** The final segment of a path. */ +function name(path: string): string { + const parts = path.split(/[\\/]/); + return parts[parts.length - 1] || path; +}