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
11 changes: 10 additions & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,9 @@ Single window. Top-level tabs are projects; each project tab contains:
diagnostics for Kubernetes kinds (bundled JSON schemas, validated in the Go
backend on save/idle — kubeconform-style). Markdown files render to a preview
with an edit toggle. This is deliberately "light editing": no refactoring, no
multi-file operations.
multi-file operations. Under the breadcrumb sits a view toolbar carrying the
active file's view toggles — the blame column, and the diff — each shown only
when the file is one it can answer for.
- **Cluster panel**: live health (from the watch service + kstatus) for every
object declared in the project, and a drift indicator when live objects differ
from the checked-in manifests (server-side dry-run comparison, computed on
Expand Down Expand Up @@ -268,6 +270,13 @@ v1 scope mirrors actual daily use, not a git client:
- Status-driven change markers in the file tree: every changed path tinted and badged where it lives, and a tree-header toggle that filters the tree down to just those paths (deletions included, struck through, since they are in no directory listing). There is no separate changes list — a second list of the same paths cost a fixed share of the sidebar to say what the tree already knew.
- Pull (rebase per repo config), push, current branch + ahead/behind in the status bar.
- Diff viewer for working-tree changes and for a file's last commit.
- A blame column beside the editor, per line: the author's initials and the
date of the commit that last touched it, with the commit's subject and short
SHA on hover. It reads the file on disk, so its entries are hidden while the
buffer holds an unsaved edit — one insertion moves every line number under
it, and a shifted blame names the wrong person. This is not history tooling:
there is no navigating to a commit from it, which stays with log browsing in
v1.x.
- Branch switching (existing branches). Branch creation, log browsing, stash,
and history tooling are v1.x (§10).

Expand Down
228 changes: 225 additions & 3 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import type { Files } from "./lib/files";
import type { Project, Registry } from "./lib/projects";
import type { Endpoint } from "./lib/stream";
import type { Git, Status } from "./lib/git";
import { MODIFIED, NOT_A_REPOSITORY, emptyStatus } from "./lib/git";
import { MODIFIED, NOT_A_REPOSITORY, UNTRACKED, emptyBlame, emptyStatus } from "./lib/git";

/**
* A full `Git` seam from the one or two calls a test actually cares about.
Expand All @@ -28,6 +28,7 @@ import { MODIFIED, NOT_A_REPOSITORY, emptyStatus } from "./lib/git";
function stubGit(overrides: Partial<Git> = {}): Git {
return {
status: () => Promise.resolve(emptyStatus()),
blame: () => Promise.resolve(emptyBlame()),
pull: () => Promise.resolve(),
push: () => Promise.resolve(),
checkout: () => Promise.resolve(),
Expand Down Expand Up @@ -1065,12 +1066,12 @@ describe("the breadcrumb above the editor (#43)", () => {
// changed under it has no row in that list to reveal.
it("leaves changed-only mode to show a directory it was filtering out", async () => {
const bar = await openTheFile();
fireEvent.click(screen.getByRole("button", { name: "show changed files only" }));
fireEvent.click(screen.getByRole("button", { name: "Changed only" }));
expect(screen.getByText("Nothing has changed in this project.")).toBeDefined();

fireEvent.click(within(bar).getByRole("button", { name: "manifests" }));

expect(screen.getByRole("button", { name: "show changed files only" })).toBeDefined();
expect(screen.getByRole("button", { name: "Changed only" })).toBeDefined();
expect(screen.getByRole("treeitem", { name: /manifests/ })).toBeDefined();
});

Expand All @@ -1081,3 +1082,224 @@ describe("the breadcrumb above the editor (#43)", () => {
expect(screen.queryByRole("navigation", { name: /^path of/ })).toBeNull();
});
});

describe("the blame column above the editor (#52)", () => {
const blame = {
commits: [
{
sha: "a1b2c3d4e5f60718293a4b5c6d7e8f9012345678",
author: "Craig Johnston",
authorTime: Math.floor(new Date(2026, 7, 6, 9, 30).getTime() / 1000),
summary: "Add the ingress",
uncommitted: false,
},
{
sha: "0000000000000000000000000000000000000000",
author: "Not Committed Yet",
authorTime: 0,
summary: "",
uncommitted: true,
},
],
lines: [0, 1],
};

function stubDirectory(): Directory {
return {
list: (_root, relPath) =>
Promise.resolve(relPath === "" ? [{ name: "ingress.yaml", isDir: false }] : []),
create: () => Promise.resolve(),
rename: () => Promise.resolve(),
remove: () => Promise.resolve(),
prefixes: () => Promise.resolve({}),
};
}

function stubFiles(): Files {
return {
read: () =>
Promise.resolve(
watch.FileContent.createFrom({
content: "kind: Ingress\nname: web\n",
crlf: false,
mixedEol: false,
readOnly: false,
size: 24,
}),
),
write: () => Promise.resolve(),
};
}

/** Opens the one file, with git answering `status` and `blame` as told. */
async function openTheFile(git = stubGit({ blame: () => Promise.resolve(blame) })) {
render(
<App
load={attached}
endpoint={pending}
backend={{
registry: fakeRegistry([project("infra", "/w/infra")]),
directory: stubDirectory(),
files: stubFiles(),
git,
}}
/>,
);
fireEvent.click(await screen.findByRole("treeitem", { name: /ingress\.yaml/ }));
await screen.findByRole("navigation", { name: "path of ingress.yaml" });
}

/** The rendered entries of the blame column, in line order. */
function entries(): string[] {
return [...document.querySelectorAll(".cm-blame-entry")].map((el) => el.textContent ?? "");
}

it("attributes each line once the column is turned on", async () => {
await openTheFile();

fireEvent.click(screen.getByRole("button", { name: "Blame" }));

await waitFor(() => {
expect(entries()).toEqual(["CJ 2026-08-06", "uncommitted"]);
});
});

it("does not read a blame for a file nobody asked about", async () => {
const git = stubGit({ blame: vi.fn(() => Promise.resolve(blame)) });
await openTheFile(git);

expect(git.blame).not.toHaveBeenCalled();
});

it("takes the column away again", async () => {
await openTheFile();
fireEvent.click(screen.getByRole("button", { name: "Blame" }));
await waitFor(() => {
expect(entries()).toHaveLength(2);
});

fireEvent.click(screen.getByRole("button", { name: "Blame" }));

expect(document.querySelector(".cm-blame")).toBeNull();
});

it("offers nothing to blame for an untracked file", async () => {
await openTheFile(
stubGit({
status: () =>
Promise.resolve({
...emptyStatus(),
files: [
{
path: "ingress.yaml",
staged: "",
worktree: UNTRACKED,
conflicted: false,
origPath: "",
},
],
}),
}),
);

await waitFor(() => {
expect(screen.queryByRole("button", { name: "Blame" })).toBeNull();
});
});

it("reports git's refusal in git's own words", async () => {
await openTheFile(
stubGit({
blame: () => Promise.reject(new Error("fatal: no such path in HEAD")),
}),
);

fireEvent.click(screen.getByRole("button", { name: "Blame" }));

expect((await screen.findByRole("alert")).textContent).toContain("no such path in HEAD");
});
});

describe("locating the open file in the tree (#56)", () => {
const listings: Record<string, { name: string; isDir: boolean }[]> = {
"": [{ name: "manifests", isDir: true }],
manifests: [{ name: "prod", isDir: true }],
"manifests/prod": [{ name: "ingress.yaml", isDir: false }],
};

function stubDirectory(): Directory {
return {
list: (_root, relPath) => Promise.resolve(listings[relPath] ?? []),
create: () => Promise.resolve(),
rename: () => Promise.resolve(),
remove: () => Promise.resolve(),
prefixes: () => Promise.resolve({}),
};
}

function stubFiles(): Files {
return {
read: () =>
Promise.resolve(
watch.FileContent.createFrom({
content: "kind: Ingress\n",
crlf: false,
mixedEol: false,
readOnly: false,
size: 14,
}),
),
write: () => Promise.resolve(),
};
}

/** Opens the nested file, then collapses the chain so nothing shows it. */
async function openThenHide() {
render(
<App
load={attached}
endpoint={pending}
backend={{
registry: fakeRegistry([project("infra", "/w/infra")]),
directory: stubDirectory(),
files: stubFiles(),
git: stubGit(),
}}
/>,
);
fireEvent.click(await screen.findByRole("treeitem", { name: /manifests/ }));
fireEvent.click(await screen.findByRole("treeitem", { name: /prod$/ }));
fireEvent.click(await screen.findByRole("treeitem", { name: /ingress\.yaml/ }));
await screen.findByRole("navigation", { name: "path of ingress.yaml" });

fireEvent.click(screen.getByRole("treeitem", { name: /manifests/ }));
expect(screen.queryByRole("treeitem", { name: /ingress\.yaml/ })).toBeNull();
}

it("brings the open file back on screen and selects it", async () => {
await openThenHide();

fireEvent.click(screen.getByRole("button", { name: "Locate" }));

const row = await screen.findByRole("treeitem", { name: /ingress\.yaml/ });
expect(row.getAttribute("aria-selected")).toBe("true");
});

// Changed-only mode is a filter over the rows, and a file with nothing
// changed has no row in that list to select.
it("leaves changed-only mode, which would otherwise have no row to select", async () => {
await openThenHide();
fireEvent.click(screen.getByRole("button", { name: "Changed only" }));
expect(screen.getByText("Nothing has changed in this project.")).toBeDefined();

fireEvent.click(screen.getByRole("button", { name: "Locate" }));

expect(await screen.findByRole("treeitem", { name: /ingress\.yaml/ })).toBeDefined();
});

it("says nothing when no file is open", async () => {
await renderWith(["infra"]);

expect(screen.queryByRole("button", { name: "Locate" })).toBeNull();
});
});
34 changes: 32 additions & 2 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type { Directory } from "./lib/directory";
import { wailsDirectory } from "./lib/directory";
import type { Files } from "./lib/files";
import { wailsFiles } from "./lib/files";
import type { Git } from "./lib/git";
import type { Git, Status } from "./lib/git";
import { wailsGit } from "./lib/git";
import type { Project, Registry } from "./lib/projects";
import { projectLabel, wailsRegistry } from "./lib/projects";
Expand All @@ -24,6 +24,7 @@ import {
preferredAppearance,
watchAppearance,
} from "./lib/theme";
import { NO_BLAME, useBlame } from "./lib/useBlame";
import { useEditorTabs } from "./lib/useEditorTabs";
import { useFileTree } from "./lib/useFileTree";
import { useGitOps } from "./lib/useGitOps";
Expand All @@ -32,6 +33,7 @@ import { useTerminals } from "./lib/useTerminals";
import { Breadcrumb } from "./components/Breadcrumb";
import { EditorPane } from "./components/EditorPane";
import { EditorTabs } from "./components/EditorTabs";
import { ViewToolbar } from "./components/ViewToolbar";

const initialStatus: BuildStatus = { info: detachedBuild, attached: false };

Expand Down Expand Up @@ -208,6 +210,9 @@ export default function App({
<Editor
project={active}
editors={editors}
status={gitStatus.status}
git={git}
onLocate={tree.locate}
appearance={appearance}
onReveal={tree.reveal}
/>
Expand Down Expand Up @@ -292,6 +297,13 @@ function BuildLine({ build }: { readonly build: BuildStatus }) {
interface EditorProps {
readonly project: Project;
readonly editors: ReturnType<typeof useEditorTabs>;
/** This project's git status (#8): what decides whether a file has a blame
* column to offer (#52). */
readonly status: Status;
/** The git seam, for the blame the column shows (#52). */
readonly git: Git;
/** Selects the open file in the tree — `FileTreeController.locate` (#56). */
onLocate: (path: string) => void;
readonly appearance: Appearance;
/** What a breadcrumb segment opens in the tree (#43). */
onReveal: (dir: string) => void;
Expand All @@ -305,10 +317,20 @@ interface EditorProps {
* unmounted on a project switch would drop its CodeMirror view, and with it
* the undo history behind whatever unsaved work the tab is holding.
*/
function Editor({ project, editors, appearance, onReveal }: EditorProps) {
function Editor({
project,
editors,
status,
git,
onLocate,
appearance,
onReveal,
}: EditorProps) {
// The strip's own tabs, not every project's: the breadcrumb describes what
// is on screen, and `activeKey` is per project.
const active = editors.visible.find((tab) => tab.key === editors.activeKey) ?? null;
// One blame, for the file on screen. See useBlame for why it is not per tab.
const blame = useBlame(active, git);

return (
<>
Expand All @@ -325,13 +347,21 @@ function Editor({ project, editors, appearance, onReveal }: EditorProps) {

<Breadcrumb tab={active} onReveal={onReveal} />

<ViewToolbar
tab={active}
status={status}
onLocate={onLocate}
onToggleBlame={editors.setBlame}
/>

<div className="editor-panes">
{editors.tabs.map((tab) => (
<EditorPane
key={tab.key}
tab={tab}
active={tab.key === editors.activeKey}
appearance={appearance}
blame={tab.key === editors.activeKey ? blame : NO_BLAME}
onChange={editors.edit}
onSave={(key) => {
void editors.save(key);
Expand Down
Loading
Loading