Skip to content
Open
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
3 changes: 3 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ A Web/PWA client for a running [Herdr](https://herdr.dev) server.
as you type. Leave the field blank to use Herdr's default directory.
- Split, resize, focus, swap (drag the handle at a pane's top center), zoom, and
close panes.
- Drag a pane's handle onto another tab to move the pane there, or onto **+**
to give it a tab of its own. Hold it over a tab to open that tab, then drop
it on a pane's edge to place it on that side. Running programs keep going.
- The command menu searches workspaces, worktrees, files, tabs, panes, and
agents, and opens typed file paths.
- Desktop **Zen mode** hides app chrome so the terminal fills the window.
Expand Down
2 changes: 1 addition & 1 deletion scripts/check-web-assets.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { fileURLToPath, URL } from "node:url";
const publicRoot = fileURLToPath(new URL("../server/public/", import.meta.url));
const maxFileCount = 160;
const maxTotalBytes = 14 * 1024 * 1024;
const maxInitialJsBytes = 802 * 1024;
const maxInitialJsBytes = 804 * 1024;
const maxInitialJsGzipBytes = 256 * 1024;
const maxInitialCssBytes = 196 * 1024;

Expand Down
86 changes: 86 additions & 0 deletions web/src/browserNavigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,92 @@ describe("store browser-local navigation", () => {
});
});

test.each([
["right", "right", false],
["down", "down", false],
["left", "right", true],
["up", "down", true],
] as const)(
"a pane placed %s of a pane in another tab splits %s (swap: %s)",
async (side, split, swaps) => {
await withBrowserStore(async (calls, topology) => {
const originalConnection = bridge.connection;
bridge.connection = (connectionId, generation) => {
const client = originalConnection(connectionId, generation);
return {
...client,
call: async (method, params) => {
const result = await client.call(method, params);
return method === "pane.move"
? {
move_result: {
pane: { ...topology.panes[0], tab_id: "a2" },
},
}
: result;
},
};
};
await store.movePaneBeside("a1p", "a2p", side);
expect(
calls
.filter(
(call) =>
call.method.startsWith("pane.") &&
call.method !== "pane.list" &&
call.method !== "pane.layout",
)
.map(({ method, params }) => [method, params]),
).toEqual([
[
"pane.move",
{
pane_id: "a1p",
destination: {
type: "tab",
tab_id: "a2",
split,
target_pane_id: "a2p",
},
focus: false,
},
],
...(swaps
? [["pane.swap", { source_pane_id: "a1p", target_pane_id: "a2p" }]]
: []),
]);
expect(store.get().browserNavigation.tabIds.a).toBe("a2");
});
},
);

test("pane moves ignore same-tab and cross-workspace targets and lone panes", async () => {
await withBrowserStore(async (calls) => {
for (const [source, target] of [
["a1p", "a1q"],
["a1p", "missing"],
["missing", "a2p"],
["a1p", "b1p"],
])
await store.movePaneBeside(source, target, "right");
await store.movePaneToNewTab("a2p");
await store.movePaneToNewTab("missing");
expect(calls.filter((call) => call.method === "pane.move")).toEqual([]);
await store.movePaneToNewTab("a1p");
expect(
calls
.filter((call) => call.method === "pane.move")
.map((call) => call.params),
).toEqual([
{
pane_id: "a1p",
destination: { type: "new_tab", workspace_id: "a" },
focus: false,
},
]);
});
});

test.each(["workspace", "tab", "agent"])(
"%s selection renders the target pane while its layout is deferred",
async (route) => {
Expand Down
43 changes: 38 additions & 5 deletions web/src/components/PaneMoveMenu.css
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,14 @@
inset: -5px;
}

.pane-layout-cell .pane-drag-handle {
.pane-layout-cell .pane-drag-handle,
.workspace-terminal-surface > .terminal-shell > .pane-drag-handle {
display: inline-flex;
}

/* A tab's only pane renders without the grid but can still move to a tab. */
.pane-layout-cell:hover .pane-drag-handle,
.workspace-terminal-surface > .terminal-shell:hover > .pane-drag-handle,
.pane-drag-handle:focus-visible,
.pane-layout-cell.is-swap-source .pane-drag-handle {
opacity: 1;
Expand All @@ -58,8 +61,8 @@
transform: translate(-50%, 0) scale(0.95);
}

.pane-layout.is-swapping,
.pane-layout.is-swapping * {
.is-pane-dragging,
.is-pane-dragging * {
cursor: grabbing !important;
user-select: none;
}
Expand All @@ -74,7 +77,8 @@
}

.pane-layout-cell.is-swap-source::after,
.pane-layout-cell.is-swap-target::after {
.pane-layout-cell.is-swap-target::after,
.is-place-target::after {
content: "";
position: absolute;
inset: 3px;
Expand All @@ -95,7 +99,32 @@
animation: pane-swap-target-in 140ms ease-out;
}

.pane-layout-cell.is-swap-target::before {
/* A pane dragged in from another tab lands on the highlighted half. */
.is-place-target[data-drop-side="left"]::after {
right: 50%;
}

.is-place-target[data-drop-side="right"]::after {
left: 50%;
}

.is-place-target[data-drop-side="up"]::after {
bottom: 50%;
}

.is-place-target[data-drop-side="down"]::after {
top: 50%;
}

.tabbar .tabbar-tab.is-pane-drop-target,
.tabbar .tabbar-add.is-pane-drop-target {
border-color: var(--accent);
background: var(--accent-soft);
color: var(--text-strong);
}

.pane-layout-cell.is-swap-target::before,
.is-place-target::before {
content: "Swap here";
position: absolute;
top: 14px;
Expand All @@ -110,6 +139,10 @@
pointer-events: none;
}

.is-place-target::before {
content: "Place here";
}

.pane-move-preview {
position: fixed;
top: 0;
Expand Down
Loading
Loading