Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 0 additions & 8 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
43 changes: 16 additions & 27 deletions gui/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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 = [
Expand All @@ -30,6 +29,7 @@ export function App() {
const [panel, setPanel] = useState<string | null>("explorer");
const [tab, setTab] = useState<string>("loc");
const [selected, setSelected] = useState<string | null>(null);
const [graphTarget, setGraphTarget] = useState("");
const [reloadToken, setReloadToken] = useState(0);
const [summary, setSummary] = useState<string | null>(null);
const [config, setConfig] = useState<string | null>(null);
Expand Down Expand Up @@ -138,7 +138,14 @@ export function App() {
{panel === "explorer" ? (
<FileTree
root={root}
onOpen={setSelected}
onOpen={(picked) => {
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}
/>
) : (
Expand Down Expand Up @@ -202,7 +209,12 @@ export function App() {
reloadToken={reloadToken}
/>
) : (
<NotBuilt label={labelFor(tab)} />
<DependenciesView
path={root}
target={graphTarget}
onTargetChange={setGraphTarget}
reloadToken={reloadToken}
/>
)}
</ScrollArea.Viewport>
<ScrollArea.Scrollbar orientation="vertical">
Expand All @@ -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. */}
<Separator.Root decorative style={{ display: "none" }} />
</div>
);
}

/** 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({
Expand All @@ -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 (
<div className={views.placeholder}>
<span className={views.placeholder__title}>{label}</span>
<span className={views.placeholder__hint}>
The command and its types already exist in{" "}
<code>gui/src-tauri/src/commands/graph.rs</code> and{" "}
<code>gui/src/lib/ipc.ts</code>. Only the React is missing, and building
it is the next round.
</span>
</div>
);
}
66 changes: 51 additions & 15 deletions gui/src/lib/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, number>;
nodes: GraphNode[];
edges: GraphEdge[];
metrics: GraphMetrics;
}

/** Count a directory. Extra patterns are merged after `.sephera.toml`'s. */
Expand Down
86 changes: 85 additions & 1 deletion gui/src/styles/views.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
}
}
Loading
Loading