Merge remote-tracking branch 'origin/main' into moderato-motif
This commit is contained in:
@@ -17,7 +17,7 @@ function stubFetch(): void {
|
||||
: url.includes("/api/projects")
|
||||
? []
|
||||
: url.includes("/api/names")
|
||||
? { names: ["allegro-cello"] }
|
||||
? { names: ["cello"] }
|
||||
: undefined;
|
||||
return new Response(JSON.stringify(body), { status: 200 });
|
||||
}),
|
||||
@@ -523,3 +523,38 @@ describe("multiview mobile single pane", () => {
|
||||
expect(mv.el.classList.contains("mv-mobile-solo")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiview solo tab bar", () => {
|
||||
beforeEach(() => {
|
||||
stubFetch();
|
||||
document.body.replaceChildren();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("hides the tab bar while only one tab exists", () => {
|
||||
const layout = emptyLayout();
|
||||
const tab = layout.tabs[0];
|
||||
tab.focusedLeafId = splitLeaf(tab, "", "s1", "row", true);
|
||||
const mv = makeMv(layout);
|
||||
document.body.appendChild(mv.el);
|
||||
mv.render();
|
||||
|
||||
expect(mv.el.classList.contains("mv-solo-tab")).toBe(true);
|
||||
});
|
||||
|
||||
it("shows the tab bar again once a second tab appears", () => {
|
||||
const layout = emptyLayout();
|
||||
const first = layout.tabs[0];
|
||||
first.focusedLeafId = splitLeaf(first, "", "s1", "row", true);
|
||||
const second = addTab(layout);
|
||||
second.focusedLeafId = splitLeaf(second, "", "s2", "row", true);
|
||||
const mv = makeMv(layout);
|
||||
document.body.appendChild(mv.el);
|
||||
mv.render();
|
||||
|
||||
expect(mv.el.classList.contains("mv-solo-tab")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -472,6 +472,10 @@ export function createMultiView(opts: MultiViewOptions): MultiView {
|
||||
// Mobile: the tab bar is a second navigation surface we don't want, so CSS
|
||||
// collapses it; the pane header's ☰ keeps the sidebar drawer reachable.
|
||||
el.classList.toggle("mv-mobile-solo", isMobile);
|
||||
// A lone tab has nothing to switch between, so the bar is only a strip of
|
||||
// chrome; CSS hides it until a second tab exists. Spawning still opens its
|
||||
// own tab (#100), which brings the bar back with the "+" button on it.
|
||||
el.classList.toggle("mv-solo-tab", layout.tabs.length === 1);
|
||||
|
||||
if (!tab.root) {
|
||||
pruneForms(tab);
|
||||
|
||||
+17
-1
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { longestCommonPrefix, splitPath } from "./paths.js";
|
||||
import { longestCommonPrefix, parentDir, splitPath } from "./paths.js";
|
||||
|
||||
describe("splitPath", () => {
|
||||
it("splits on the last slash, keeping it with the directory", () => {
|
||||
@@ -29,3 +29,19 @@ describe("longestCommonPrefix", () => {
|
||||
expect(longestCommonPrefix(["Alpha", "alpha"])).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("parentDir", () => {
|
||||
it("drops the last segment", () => {
|
||||
expect(parentDir("/home/user/tea/maestro")).toBe("/home/user/tea");
|
||||
expect(parentDir("/home/user/tea/maestro/")).toBe("/home/user/tea");
|
||||
});
|
||||
|
||||
it("keeps the root for a top-level path", () => {
|
||||
expect(parentDir("/maestro")).toBe("/");
|
||||
expect(parentDir("/")).toBe("/");
|
||||
});
|
||||
|
||||
it("is empty for a bare name", () => {
|
||||
expect(parentDir("maestro")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,3 +19,12 @@ export function longestCommonPrefix(items: string[]): string {
|
||||
}
|
||||
return lcp;
|
||||
}
|
||||
|
||||
/** Everything above the last segment of an absolute path: `/a/b/c` → `/a/b`.
|
||||
* A top-level path keeps `/`, and a trailing slash is ignored. */
|
||||
export function parentDir(path: string): string {
|
||||
const trimmed = path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
|
||||
const slash = trimmed.lastIndexOf("/");
|
||||
if (slash <= 0) return slash === 0 ? "/" : "";
|
||||
return trimmed.slice(0, slash);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ function stubFetch(info: unknown, projects: Project[]): void {
|
||||
: url.includes("/api/projects")
|
||||
? projects
|
||||
: url.includes("/api/names")
|
||||
? { names: ["allegro-cello", "presto-fugue", "adagio-lyre"] }
|
||||
? { names: ["cello", "fugue", "lyre"] }
|
||||
: undefined;
|
||||
return new Response(JSON.stringify(body), { status: 200 });
|
||||
}),
|
||||
@@ -79,14 +79,22 @@ describe("spawnForm project picker (#95: boxes, full paths, mains only)", () =>
|
||||
expect(boxes[0].classList.contains("is-stale")).toBe(false);
|
||||
});
|
||||
|
||||
it("shows the name as the primary line and the full path unabbreviated", async () => {
|
||||
it("shows the name as the primary line and its parent directory below", async () => {
|
||||
const p = project({ name: "nixos-config", path: "/home/user/tea/nixos-config" });
|
||||
const form = (await mount([p])).el;
|
||||
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
|
||||
expect(box.querySelector(".project-name")!.textContent).toBe("nixos-config");
|
||||
// Not `~/t/nixos-config`: the abbreviation hid what distinguishes two
|
||||
// projects sharing a basename (#95).
|
||||
expect(box.querySelector(".project-path")!.textContent).toBe("/home/user/tea/nixos-config");
|
||||
// Only the stem: repeating the last segment would just echo the name. Not
|
||||
// `~/t`, since the stem is what distinguishes two projects sharing a
|
||||
// basename (#95).
|
||||
expect(box.querySelector(".project-path")!.textContent).toBe("~/tea");
|
||||
});
|
||||
|
||||
it("keeps the last command out of the box", async () => {
|
||||
const p = project({ name: "maestro", path: "/home/user/maestro", last_command: "claude" });
|
||||
const form = (await mount([p])).el;
|
||||
const box = form.querySelector<HTMLButtonElement>(".project-box")!;
|
||||
expect(box.textContent).not.toContain("claude");
|
||||
});
|
||||
|
||||
it("lists only main checkouts as boxes — worktrees belong to step two", async () => {
|
||||
@@ -126,11 +134,11 @@ describe("spawnForm project picker (#95: boxes, full paths, mains only)", () =>
|
||||
expect(form.querySelector(".picker-empty")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("picking a project selects its cwd and pre-selects its last command", async () => {
|
||||
it("picking a project selects its cwd and leaves the command alone", async () => {
|
||||
const p = project({
|
||||
name: "maestro",
|
||||
path: "/home/user/maestro",
|
||||
last_command: "claude",
|
||||
last_command: "aider",
|
||||
});
|
||||
const onChange = vi.fn();
|
||||
stubFetch(
|
||||
@@ -157,6 +165,8 @@ describe("spawnForm project picker (#95: boxes, full paths, mains only)", () =>
|
||||
const values = form.querySelector<HTMLInputElement>("input[name=name]")!.value;
|
||||
const last = onChange.mock.lastCall![0];
|
||||
expect(last.cwd).toBe("/home/user/maestro");
|
||||
// The directory and the command are independent choices: the preset stays
|
||||
// where it was, whatever the project was last run with.
|
||||
expect(last.command).toBe("claude");
|
||||
expect(values).toBeTruthy();
|
||||
// The raw input stays hidden — the picked box is the source of truth.
|
||||
|
||||
+13
-11
@@ -8,8 +8,10 @@ import {
|
||||
gitRoot,
|
||||
} from "./api.js";
|
||||
import { attachDirAutocomplete } from "./dirAutocomplete.js";
|
||||
import { parentDir } from "./paths.js";
|
||||
import { filterProjects, groupProjects, isStale, sortProjects } from "./projectList.js";
|
||||
import type { ProjectGroup } from "./projectList.js";
|
||||
import { abbreviateCwd } from "./types.js";
|
||||
import type { BranchInfo, CreateSessionRequest, Project, Session, SpawnPreset } from "./types.js";
|
||||
|
||||
/** Where a worktree's branch comes from: freshly created (default) or picked
|
||||
@@ -440,8 +442,9 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
|
||||
// ---- working-directory picker (#95) ----------------------------------
|
||||
// Two steps, and the TUI mirror (#96) must match these rules verbatim:
|
||||
// 1. Step one is one box per repository (name, then full path). A worktree
|
||||
// is a box only when its main checkout is missing from the scan.
|
||||
// 1. Step one is one box per repository (name, then its parent directory).
|
||||
// A worktree is a box only when its main checkout is missing from the
|
||||
// scan.
|
||||
// 2. The filter keeps a repository when the query matches it or any of its
|
||||
// worktrees, so a worktree name still finds its box.
|
||||
// 3. Picking a box reveals step two: "+ new", then that repository's
|
||||
@@ -454,6 +457,8 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
// these are separate acts — #91's rule, now an explicit choice.
|
||||
// 6. Neither step probes /api/gitroot; the scan already reports repository
|
||||
// membership. Only the free-text hatch pays for a probe.
|
||||
// 7. Neither step touches the command: the directory and what runs in it
|
||||
// are independent choices.
|
||||
|
||||
/** "new" (create a worktree) or the path of an existing worktree to run in. */
|
||||
type WorktreeChoice = "new" | string;
|
||||
@@ -544,9 +549,6 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
wtHint.hidden = true;
|
||||
}
|
||||
emit();
|
||||
// The chosen directory pre-selects the command it was last used with.
|
||||
const match = presets.find((p) => p.command === target.last_command && p.command !== "");
|
||||
if (match) selectPreset(match.id);
|
||||
}
|
||||
|
||||
/** Groups with at least one member (itself or a worktree) matching `query`. */
|
||||
@@ -634,14 +636,14 @@ export function createSpawnForm(opts: SpawnFormOptions): SpawnFormHandle {
|
||||
box.className = `project-box${isStale(project, new Date()) ? " is-stale" : ""}`;
|
||||
box.dataset.cwd = project.path;
|
||||
const trees = worktreesOf(group).length;
|
||||
// The full path, not the sidebar's `~/t/n/maestro` abbreviation: it is what
|
||||
// tells two projects sharing a basename apart (#95).
|
||||
const meta = [trees > 0 ? `${trees} worktree${trees === 1 ? "" : "s"}` : "", project.last_command ?? ""]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
// The parent directory only — the last segment is the name line above it.
|
||||
// Unabbreviated beyond `~`, since the stem is what tells two projects
|
||||
// sharing a basename apart (#95).
|
||||
const parent = abbreviateCwd(parentDir(project.path), homeDir);
|
||||
const meta = trees > 0 ? `${trees} worktree${trees === 1 ? "" : "s"}` : "";
|
||||
box.innerHTML = `
|
||||
<span class="project-name">${escapeHtml(project.name)}</span>
|
||||
<span class="project-path">${escapeHtml(project.path)}</span>
|
||||
<span class="project-path">${escapeHtml(parent)}</span>
|
||||
<span class="project-meta">${escapeHtml(meta)}</span>`;
|
||||
box.addEventListener("click", () => selectProject(group));
|
||||
return box;
|
||||
|
||||
@@ -3,8 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createSpawnForm, type SpawnFormHandle } from "./spawnForm.js";
|
||||
|
||||
const BATCH_A = ["allegro-cello", "presto-fugue", "adagio-lyre"];
|
||||
const BATCH_B = ["forte-oboe", "largo-tuba", "vivace-harp"];
|
||||
const BATCH_A = ["cello", "fugue", "lyre"];
|
||||
const BATCH_B = ["oboe", "tuba", "harp"];
|
||||
|
||||
/** Stub the three fetches the form's initial load makes. Records every
|
||||
* /api/names URL so tests can assert how many batches were fetched. */
|
||||
|
||||
+2
-1
@@ -2096,7 +2096,8 @@ input.leaf-rename:focus {
|
||||
}
|
||||
/* Mobile: the tab bar is a second navigation surface — collapse it (a floating
|
||||
☰ inside the pane keeps the sidebar drawer reachable). */
|
||||
.mv-mobile-solo > .mv-tabs {
|
||||
.mv-mobile-solo > .mv-tabs,
|
||||
.mv-solo-tab > .mv-tabs {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user