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
5 changes: 5 additions & 0 deletions DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,11 @@ registry gained in the meantime.
Nothing is written into the managed repos in v1 — they stay pristine manifest
repos. (A shared in-repo `.m6t.yaml` for team defaults is a v2 candidate.)

The second file in that directory is `session.yaml`: the workspace m6t was last
closed in — the open project, and per project the editor tabs, the tree's shape and the terminal tabs, plus the window-wide settings around them. It is written by `internal/session` half a second after the workspace stops changing, and it is deliberately not part of `projects.yaml`, because the two answer a broken file in opposite ways. A registry that will not parse is an error the user is shown, since starting from empty would look like an app that had forgotten every project they have. A session that will not parse is replaced by the defaults without a word: everything in it is a click away, and a dialog about a file nobody knew existed would be worse than the workspace it was describing.

Restoring is per project and lazy. The window-wide settings come back at launch; a project's tabs come back the first time it is the project on screen, so a registry of ten repositories does not read a file and start a shell for each of their tabs before the user has asked for any of it. Every restored reference is checked against a workspace that may have moved on: a file that will not read is a tab that does not come back, a directory that is gone is a terminal opened at the project root instead, and a project the registry no longer holds loses its record.

The kube context binding is **per project and explicit**. m6t never uses the
kubeconfig current-context; a project with no bound context shows the cluster
panel and apply actions disabled with a "bind a context" prompt.
Expand Down
58 changes: 57 additions & 1 deletion frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
import { afterEach, describe, expect, it, vi } from "vitest";
import App from "./App";
import { detachedBuild } from "./lib/build";
import { project as models, watch } from "../wailsjs/go/models";
import { project as models, session as sessionModels, watch } from "../wailsjs/go/models";
import type { Directory } from "./lib/directory";
import type { Files } from "./lib/files";
import type { Project, Registry } from "./lib/projects";
Expand Down Expand Up @@ -1303,3 +1303,59 @@ describe("locating the open file in the tree (#56)", () => {
expect(screen.queryByRole("button", { name: "Locate" })).toBeNull();
});
});

/**
* The session seam, wired through `App` rather than through the hook.
*
* `lib/useSession.test.ts` covers what restoring does; what is left to prove
* here is that the composition root actually hands the store to it, and hands
* the restored pane sizes to the workbench. Both are one line in `App` and
* both fail silently — the app simply comes back at its defaults.
*/
describe("the saved session (#58)", () => {
it("opens the project the session recorded rather than the first one", async () => {
const registry = fakeRegistry([project("infra"), project("apps")]);
const store = {
load: () =>
Promise.resolve(
sessionModels.State.createFrom({ version: 1, activeProject: "apps", projects: [] }),
),
save: vi.fn(() => Promise.resolve()),
};

render(<App load={attached} endpoint={pending} backend={{ registry, session: store }} />);

await waitFor(() => {
expect(
screen.getByRole("button", { name: "apps" }).getAttribute("aria-current"),
).toBe("page");
});
});

it("draws the workbench at the pane sizes the session recorded", async () => {
const registry = fakeRegistry([project("infra")]);
const store = {
load: () =>
Promise.resolve(
sessionModels.State.createFrom({
version: 1,
activeProject: "infra",
sidebar: 342,
terminalHeight: 208,
projects: [],
}),
),
save: vi.fn(() => Promise.resolve()),
};

const { container } = render(
<App load={attached} endpoint={pending} backend={{ registry, session: store }} />,
);

await waitFor(() => {
const workbench = container.querySelector(".workbench");
expect(workbench?.getAttribute("style")).toContain("342px");
expect(workbench?.getAttribute("style")).toContain("208px");
});
});
});
28 changes: 22 additions & 6 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,12 @@ import { wailsGit } from "./lib/git";
import type { Project, Registry } from "./lib/projects";
import { projectLabel, wailsRegistry } from "./lib/projects";
import { useProjects } from "./lib/useProjects";
import type { SessionStore } from "./lib/session";
import { wailsSession } from "./lib/session";
import { useSession } from "./lib/useSession";
import type { Endpoint } from "./lib/stream";
import type { Appearance } from "./lib/theme";
import {
DEFAULT_FONT_SIZE,
MAX_FONT_SIZE,
MIN_FONT_SIZE,
clampFontSize,
Expand Down Expand Up @@ -55,6 +57,7 @@ export interface Backend {
readonly directory: Directory;
readonly files: Files;
readonly git: Git;
readonly session: SessionStore;
}

/** Every seam backed by its generated Wails binding. */
Expand All @@ -63,6 +66,7 @@ export const wailsBackend: Backend = {
directory: wailsDirectory,
files: wailsFiles,
git: wailsGit,
session: wailsSession,
};

export interface AppProps {
Expand All @@ -87,15 +91,14 @@ export default function App({
endpoint = StreamEndpoint,
backend,
}: AppProps) {
const { registry, directory, files, git } = { ...wailsBackend, ...backend };
const { registry, directory, files, git, session } = { ...wailsBackend, ...backend };

const [build, setBuild] = useState<BuildStatus>(initialStatus);
const [stream, setStream] = useState<Endpoint | null>(null);
const [streamError, setStreamError] = useState<string | null>(null);

const projects = useProjects(registry);

const [fontSize, setFontSize] = useState(DEFAULT_FONT_SIZE);
const [appearance, setAppearance] = useState<Appearance>(preferredAppearance);

const terminals = useTerminals(projects.activeName);
Expand Down Expand Up @@ -165,6 +168,17 @@ export default function App({
// one source for what the repository looks like (PROTOCOL.md §5, `git`).
const gitOps = useGitOps(activePath, gitStatus.refresh, git);

// Declared after every hook it restores into: the tree resets itself on a
// project switch, and a restore registered ahead of that reset would be
// undone by it in the same commit.
const { workspace, setWorkspace } = useSession({
projects,
editors,
terminals,
tree,
store: session,
});

const handleOpenFile = useCallback(
(path: string) => {
if (active !== null) {
Expand Down Expand Up @@ -206,6 +220,8 @@ export default function App({
git={gitStatus}
gitOps={gitOps}
onOpenFile={handleOpenFile}
panes={workspace}
onPanes={setWorkspace}
editor={
<Editor
project={active}
Expand All @@ -223,7 +239,7 @@ export default function App({
stream={stream}
streamError={streamError}
terminals={terminals}
fontSize={fontSize}
fontSize={workspace.fontSize}
appearance={appearance}
/>
}
Expand All @@ -235,9 +251,9 @@ export default function App({
<span className="statusbar__spacer" />
<BuildLine build={build} />
<FontSize
size={fontSize}
size={workspace.fontSize}
onChange={(px) => {
setFontSize(clampFontSize(px));
setWorkspace({ fontSize: clampFontSize(px) });
}}
/>
</footer>
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/FileTree.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ function fakeController(
select: vi.fn(),
reveal: vi.fn(),
locate: vi.fn(),
restore: vi.fn(),
toggleHidden: vi.fn(),
toggleChangedOnly: vi.fn(),
createEntry: vi.fn().mockResolvedValue(null),
Expand Down
52 changes: 52 additions & 0 deletions frontend/src/components/Workbench.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { describe, expect, it } from "vitest";
import {
EDITOR_MIN_HEIGHT,
EDITOR_MIN_WIDTH,
SIDEBAR_MIN,
TERMINAL_MIN,
} from "../lib/panes";
import { fit } from "./Workbench";

/**
* Fitting the splits to the window.
*
* The separators clamp what a drag produces, which is enough for every size the
* user made themselves. A restored session is the case that has no drag behind
* it (#58): the sizes were recorded on whatever display the workspace was last
* open on, and the window they are being drawn in now may be smaller.
*/
describe("fitting the panes to the window", () => {
const panes = { sidebar: 300, terminalHeight: 240 };

it("leaves sizes the window can hold alone", () => {
expect(fit(panes, { width: 1600, height: 1000 })).toEqual(panes);
});

// The unmeasured case: the first frame, and every jsdom test. Guessing at an
// upper bound there would collapse both panes to their minimums on load.
it("keeps only the minimums while the window has not been measured", () => {
expect(fit({ sidebar: 40, terminalHeight: 10 }, { width: 0, height: 0 })).toEqual({
sidebar: SIDEBAR_MIN,
terminalHeight: TERMINAL_MIN,
});
expect(fit(panes, { width: 0, height: 0 })).toEqual(panes);
});

// A session saved on a docked display, reopened on the laptop alone.
it("shrinks a split that no longer leaves room for what is beside it", () => {
const fitted = fit({ sidebar: 1200, terminalHeight: 900 }, { width: 900, height: 700 });

expect(fitted.sidebar).toBe(900 - EDITOR_MIN_WIDTH);
expect(fitted.terminalHeight).toBe(700 - EDITOR_MIN_HEIGHT);
});

// Both minimums cannot be honoured in a window this small. The sized pane
// keeps its floor and the other one gives, which is `clampSplit`'s rule and
// is asserted here because a restore is where the tiny window shows up.
it("holds the floor when the window cannot honour both minimums", () => {
const fitted = fit({ sidebar: 1200, terminalHeight: 900 }, { width: 300, height: 200 });

expect(fitted.sidebar).toBe(SIDEBAR_MIN);
expect(fitted.terminalHeight).toBe(TERMINAL_MIN);
});
});
65 changes: 61 additions & 4 deletions frontend/src/components/Workbench.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,9 @@ import { branchSummary } from "../lib/gitStatus";
import {
EDITOR_MIN_HEIGHT,
EDITOR_MIN_WIDTH,
SIDEBAR_DEFAULT,
SIDEBAR_MIN,
TERMINAL_DEFAULT,
TERMINAL_MIN,
clampSplit,
} from "../lib/panes";

export interface WorkbenchProps {
Expand All @@ -38,6 +37,22 @@ export interface WorkbenchProps {
readonly editor: ReactNode;
/** The terminal strip and panes for this project. */
readonly terminals: ReactNode;
/**
* The two split sizes, in pixels, and how a change to them is reported.
*
* They are the caller's rather than this component's because they outlive it:
* the session restores them at launch and records them as they are dragged
* (#58), and a workbench that owned them would reset both every time the
* project strip had no project to show.
*/
readonly panes: PaneSizes;
readonly onPanes: (next: PaneSizes) => void;
}

/** The workbench's two split sizes, in pixels. */
export interface PaneSizes {
readonly sidebar: number;
readonly terminalHeight: number;
}

/**
Expand All @@ -61,11 +76,32 @@ export function Workbench({
onOpenFile,
editor,
terminals,
panes,
onPanes,
}: WorkbenchProps) {
const [sidebar, setSidebar] = useState(SIDEBAR_DEFAULT);
const [terminalHeight, setTerminalHeight] = useState(TERMINAL_DEFAULT);
const { sidebar, terminalHeight } = panes;
const { extent, frame } = useExtent();

const setSidebar = (next: number) => {
onPanes({ ...panes, sidebar: next });
};
const setTerminalHeight = (next: number) => {
onPanes({ ...panes, terminalHeight: next });
};

// A size can arrive too large for the window it is being drawn in: a session
// saved on a docked display, restored on the laptop alone. The separators
// clamp what a drag produces, which never runs for a size nobody dragged, so
// the same bounds are applied here the moment the workbench knows how big it
// is — and again whenever that changes, which is also what stops a pane from
// keeping a width the window no longer has after being shrunk.
useEffect(() => {
const fitted = fit(panes, extent);
if (fitted.sidebar !== sidebar || fitted.terminalHeight !== terminalHeight) {
onPanes(fitted);
}
}, [panes, extent, sidebar, terminalHeight, onPanes]);

return (
<div
ref={frame}
Expand Down Expand Up @@ -123,6 +159,27 @@ export function Workbench({
);
}

/** Both splits held inside the bounds an extent allows — `clampSplit`'s rules,
* applied to a pair. An unmeasured extent drops the upper bound, which is what
* keeps the minimums working in jsdom and in the first frame. */
export function fit(
panes: PaneSizes,
extent: { readonly width: number; readonly height: number },
): PaneSizes {
return {
sidebar: clampSplit(panes.sidebar, {
min: SIDEBAR_MIN,
minOther: EDITOR_MIN_WIDTH,
total: extent.width,
}),
terminalHeight: clampSplit(panes.terminalHeight, {
min: TERMINAL_MIN,
minOther: EDITOR_MIN_HEIGHT,
total: extent.height,
}),
};
}

/**
* The workbench's own size, for clamping a drag against the window.
*
Expand Down
Loading