Skip to content
Closed
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ between releases do not reach them. Remote-PC runtime bundles are versioned sepa
## [Unreleased]

### Added
- **New tab can choose its folder.** The dialog shows the same editable Directory field, Browse
button and recent folders as New workspace. The field starts at the workspace's folder (a
worktree's checkout, else the pane in front), and a link's `cwd` fills it. The folder applies to
the new tab only; the panes already open keep theirs. A folder a tab was made in is remembered
like a workspace's. ([#890](https://github.com/devswha/herdr-web-ui/pull/890) by @willowite)
- **Two tabs side by side** on a desktop, from different workspaces or PCs: drag a tab down out
of its strip, or a sidebar row over the pane area, and drop it on the left or right half. A guide
shows the two halves as soon as the drag starts and keeps its side steady at the centre; Escape
Expand Down
32 changes: 27 additions & 5 deletions scripts/ui-regression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1429,18 +1429,24 @@ try {
worktreeWorkspaces.splice(worktreeWorkspaces.indexOf(worktree.workspace_id), 1);
console.log("PASS a worktree row sits under its repository's row, and its menu deletes the checkout, asking twice for a dirty one");

// A second tab from the tab strip's +: the dialog is New tab, with the workspace's folder shown
// and not asked for; the new pane opens, the workspace stays one row, and a strip over the pane
// lists both tabs from then on. The header's More menu has New tab too, and opens the same dialog.
// A second tab from the tab strip's +: the dialog is New tab, with the same folder field,
// Browse and recent folders as New workspace, the field starting at the workspace's folder. A
// folder typed there is the new tab's alone: the first tab's pane keeps its own. The new pane
// opens, the workspace stays one row, and a strip over the pane lists both tabs from then on.
// The header's More menu has New tab too, and opens the same dialog.
// The new pane's terminal takes the focus once it paints, which would close a menu opened before.
await until(() => painted.has(created.pane_id), "created pane paint");
await page.locator(`.pane-select[title^="${created.pane_id} —"]`).click();
await until(async () => (await page.locator(`.pane-select[title^="${created.pane_id} —"]`).getAttribute("aria-current")) === "true", "the created workspace is selected again");
await page.locator(".tab-strip-add").click();
const tabDialog = page.getByRole("dialog", { name: /^New tab · herdr-web-ui-test-browser-created/ });
await tabDialog.waitFor();
assert.equal(await tabDialog.locator(".new-session-folder").textContent(), root, "the folder is the workspace's, shown");
assert.equal(await tabDialog.getByRole("button", { name: "Browse", exact: true }).count(), 0, "the folder is not asked for");
const tabFolderField = tabDialog.locator("#new-session-cwd");
assert.equal(await tabFolderField.inputValue(), root, "the folder field starts at the workspace's folder");
assert.equal(await tabDialog.getByRole("button", { name: "Browse", exact: true }).count(), 1, "the new tab can choose its own folder");
const tabFolder = join(root, "tab-folder");
mkdirSync(tabFolder);
await tabFolderField.fill(tabFolder);
const tabNameField = tabDialog.getByLabel(/^Name/);
await tabNameField.fill("second");
// The command palette takes the keyboard above a dialog. Escape closes only the palette,
Expand All @@ -1456,6 +1462,9 @@ try {
await tabDialog.getByRole("button", { name: "Start", exact: true }).click();
const createdTab = await (await tabResponse).json() as WorkspaceCreated;
assert.equal(createdTab.workspace_id, created.workspace_id, "the tab joins the workspace");
const paneFolder = async (paneId: string): Promise<string | null | undefined> => (await sessionSnapshot()).panes.find((pane) => pane.pane_id === paneId)?.cwd;
await until(async () => await paneFolder(createdTab.pane_id) === tabFolder, "the new tab's pane opens in the chosen folder");
assert.equal(await paneFolder(created.pane_id), root, "the first tab's pane keeps its folder");
await tabDialog.waitFor({ state: "hidden" });
await until(async () => (await page.locator(`.pane-select[title^="${createdTab.pane_id} —"]`).getAttribute("aria-current")) === "true", "the new tab's pane is selected, and the row shows it");
assert.equal(await page.locator(`.pane-select[title^="${created.pane_id} —"]`).count(), 0, "the workspace stays one row");
Expand All @@ -1478,6 +1487,19 @@ try {
await runMoreItem(page, "New tab");
await tabDialog.waitFor();
assert.equal(await tabDialog.getByRole("button", { name: "Start", exact: true }).isDisabled(), false, "a reopened dialog is not left pending");
assert.equal(await tabDialog.locator("#new-session-cwd").inputValue(), root, "the pane in front, not the last tab's folder, seeds the field");
// a folder a tab was made in is remembered like a workspace's (temporary folders, which this
// run's are, are left out of the chips); the chips are the same ones New workspace has
const remembered = await page.evaluate(() => JSON.parse(localStorage.getItem("herdr-web-ui:recent-directories:local") ?? "[]") as string[]);
assert.ok(remembered.some((path) => path.replaceAll("\\", "/") === tabFolder.replaceAll("\\", "/")), "the tab's folder is among the recent folders");
await page.evaluate(() => localStorage.setItem("herdr-web-ui:recent-directories:local", JSON.stringify(["/herdr-web-ui-recent-probe/project"])));
await page.keyboard.press("Escape");
await tabDialog.waitFor({ state: "hidden" });
await runMoreItem(page, "New tab");
await tabDialog.waitFor();
await tabDialog.getByRole("group", { name: "Recent folders", exact: true }).getByRole("button", { name: /project$/ }).click();
assert.equal(await tabDialog.locator("#new-session-cwd").inputValue(), "/herdr-web-ui-recent-probe/project", "a recent folder fills the new tab's field");
console.log("PASS New tab starts in the workspace's folder, opens in the folder typed, keeps the first pane's, and offers recent folders");
await page.keyboard.press("Escape");
await tabDialog.waitFor({ state: "hidden" });
// the PC's + is New workspace again, not a tab in the workspace the last dialog was for
Expand Down
2 changes: 1 addition & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1247,7 +1247,7 @@ export function App() {
}, [machines, selectTarget]);

// a link to the New workspace dialog opens it once the PCs are in; one for a tab, once the
// linked workspace's pane is selected (its folder is the workspace's, as the tab strip's + does)
// linked workspace's pane is selected (its folder field starts as the tab strip's + does, unless the link names one)
const [createTick, setCreateTick] = useState(0);
useEffect(() => {
const linked = pendingCreate.current;
Expand Down
27 changes: 0 additions & 27 deletions src/components/NewSessionDialog.css
Original file line number Diff line number Diff line change
Expand Up @@ -103,33 +103,6 @@
white-space: nowrap;
}

/* New tab: the folder is the workspace's, shown as a fact in a dashed box, not asked for */
.new-session-folder {
display: flex;
align-items: center;
gap: var(--space-2);
min-height: var(--control-h);
padding: 0 var(--space-3);
border: var(--hairline) dashed var(--border);
border-radius: var(--radius-md);
color: var(--text-dim);
font-size: var(--fs-sm);
}

.new-session-folder svg {
flex: 0 0 auto;
width: 16px;
height: 16px;
}

.new-session-folder span {
min-width: 0;
overflow: hidden;
color: var(--text);
text-overflow: ellipsis;
white-space: nowrap;
}

@media (pointer: coarse) {
.new-session-modal .input,
.new-session-modal .select,
Expand Down
50 changes: 20 additions & 30 deletions src/components/NewSessionDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { nativeModalOver, useFocusTrap } from "../lib/useFocusTrap.ts";
import { useSheetSwipe } from "../lib/sheets.ts";
import { passingFolder, recentDirectories } from "../lib/recent-directories.ts";

/** The dialog as New tab: the workspace the tab joins, whose folder it uses, and the number herdr will give it. */
/** The dialog as New tab: the workspace the tab joins, the folder its field starts with, and the number herdr will give it. */
export interface NewTabTarget {
workspaceId: string;
workspaceLabel: string;
Expand Down Expand Up @@ -62,7 +62,7 @@ export function NewSessionDialog({ open, defaultCwd, tab = null, onClose, onCrea
const { createTab, createWorkspace, fetchAgentKinds } = useMachineApi();
const [agents, setAgents] = useState<AgentKind[]>([]);
const [agentKind, setAgentKind] = useState(rememberedAgent);
const [cwd, setCwd] = useState(defaultCwd ?? "");
const [cwd, setCwd] = useState((tab ? tab.cwd : defaultCwd) ?? "");
const [name, setName] = useState("");
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
Expand All @@ -74,15 +74,16 @@ export function NewSessionDialog({ open, defaultCwd, tab = null, onClose, onCrea
const firstFieldRef = useRef<HTMLButtonElement>(null);
const surface = useFocusTrap<HTMLFormElement>(open, { initialFocus: firstFieldRef });
useSheetSwipe(surface, pending ? null : onClose);
const defaultCwdRef = useRef(defaultCwd);
defaultCwdRef.current = defaultCwd;
// a tab starts in its own folder (a worktree's checkout, else the pane in front), not the selected pane's
const startCwdRef = useRef((tab ? tab.cwd : defaultCwd) ?? "");
startCwdRef.current = (tab ? tab.cwd : defaultCwd) ?? "";
const prefillRef = useRef(prefill);
prefillRef.current = prefill;

useEffect(() => {
if (!open) return;
const filled = prefillRef.current;
setCwd(filled?.cwd ?? defaultCwdRef.current ?? "");
setCwd(filled?.cwd ?? startCwdRef.current);
setName(filled?.name ?? "");
setError(null);
setPending(false);
Expand Down Expand Up @@ -140,17 +141,17 @@ export function NewSessionDialog({ open, defaultCwd, tab = null, onClose, onCrea
try {
rememberAgent(agentKind);
const agent = agentKind ? { kind: agentKind } : null;
// a tab keeps the workspace's folder, which the dialog shows and does not ask for
// the folder applies to the new tab only: the workspace's other panes keep theirs
const result = tab
? await createTab({ workspace_id: tab.workspaceId, cwd: tab.cwd, label: name.trim() || null, agent })
? await createTab({ workspace_id: tab.workspaceId, cwd: directory.trim() || null, label: name.trim() || null, agent })
: await createWorkspace({ cwd: directory.trim() || null, label: name.trim() || null, agent });
if (agentKind && !result.agent_started && result.error?.message) {
setPending(false);
setError(result.error.message);
setCreatedPaneId(result.pane_id);
return;
}
if (!tab && (!agentKind || result.agent_started)) {
if (!agentKind || result.agent_started) {
setRecentFolders(recentDirectories.remember(machineId, [directory.trim()], true));
}
// the dialog closes on this: left pending, its next opening would start a frame with the
Expand Down Expand Up @@ -182,30 +183,19 @@ export function NewSessionDialog({ open, defaultCwd, tab = null, onClose, onCrea
<span className="field-label" id="new-session-agent">{t("Agent")}</span>
<AgentPicker ref={firstFieldRef} agents={agents} value={agentKind} disabled={fieldsDisabled} labelledBy="new-session-agent" onChange={setAgentKind} />
</div>
{tab ? (
<div className="field">
<span className="field-label">{t("Directory")}</span>
<div className="new-session-folder">
<div className="field">
<label className="field-label" htmlFor="new-session-cwd">{t("Directory")}</label>
<div className="new-session-cwd">
<input id="new-session-cwd" className="input" value={cwd} disabled={fieldsDisabled} autoComplete="off" onChange={(event) => setCwd(event.target.value)} />
<button type="button" className="btn" aria-expanded={browsing} disabled={fieldsDisabled} onClick={() => setBrowsing((open) => !open)}>
<FolderOpen aria-hidden="true" />
<span>{tab.cwd ?? tab.workspaceLabel}</span>
</div>
<span className="field-hint">{t("Uses the workspace's folder")}</span>
</div>
) : (
<div className="field">
<label className="field-label" htmlFor="new-session-cwd">{t("Directory")}</label>
<div className="new-session-cwd">
<input id="new-session-cwd" className="input" value={cwd} disabled={fieldsDisabled} autoComplete="off" onChange={(event) => setCwd(event.target.value)} />
<button type="button" className="btn" aria-expanded={browsing} disabled={fieldsDisabled} onClick={() => setBrowsing((open) => !open)}>
<FolderOpen aria-hidden="true" />
{t("Browse")}
</button>
</div>
{browsing && <DirectoryBrowser start={cwd} onPick={(picked) => { setCwd(picked); setBrowsing(false); }} />}
<span className="field-hint">{t("absolute path or ~/…")}</span>
{t("Browse")}
</button>
</div>
)}
{!tab && shownFolders.length > 0 && <div className="field new-session-recents">
{browsing && <DirectoryBrowser start={cwd} onPick={(picked) => { setCwd(picked); setBrowsing(false); }} />}
<span className="field-hint">{t("absolute path or ~/…")}</span>
</div>
{shownFolders.length > 0 && <div className="field new-session-recents">
<span className="field-label" id="new-session-recents">{t("Recent folders")}</span>
<RecentFolderChips folders={shownFolders} disabled={fieldsDisabled} labelledBy="new-session-recents" basename={directoryBasename}
onPick={(path) => { setCwd(path); setBrowsing(false); }} />
Expand Down
1 change: 0 additions & 1 deletion src/lib/i18n.ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -943,7 +943,6 @@ export const JA: Record<string, string> = {
"New tab in {workspace}": "{workspace} に新しいタブ",
"Tab {n}": "タブ {n}",
"Tab name (optional)": "タブ名 (任意)",
"Uses the workspace's folder": "ワークスペースのフォルダーをそのまま使います",
"Rename tab": "タブの名前を変更",
"Close tab": "タブを閉じる",
"Tab name": "タブ名",
Expand Down
1 change: 0 additions & 1 deletion src/lib/i18n.ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -941,7 +941,6 @@ export const KO: Record<string, string> = {
"New tab in {workspace}": "{workspace}에 새 탭",
"Tab {n}": "탭 {n}",
"Tab name (optional)": "탭 이름 (선택)",
"Uses the workspace's folder": "워크스페이스의 폴더를 그대로 씁니다",
"Rename tab": "탭 이름 바꾸기",
"Close tab": "탭 닫기",
"Tab name": "탭 이름",
Expand Down
1 change: 0 additions & 1 deletion src/lib/i18n.tr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -896,7 +896,6 @@ export const TR: Record<string, string> = {
"New tab in {workspace}": "{workspace} içinde yeni sekme",
"Tab {n}": "Sekme {n}",
"Tab name (optional)": "Sekme adı (isteğe bağlı)",
"Uses the workspace's folder": "Çalışma alanının klasörünü kullanır",
"Rename tab": "Sekmeyi yeniden adlandır",
"Close tab": "Sekmeyi kapat",
"Tab name": "Sekme adı",
Expand Down
1 change: 0 additions & 1 deletion src/lib/i18n.zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -945,7 +945,6 @@ export const ZH: Record<string, string> = {
"New tab in {workspace}": "在 {workspace} 中新建标签页",
"Tab {n}": "标签页 {n}",
"Tab name (optional)": "标签页名称(可选)",
"Uses the workspace's folder": "直接使用工作区的文件夹",
"Rename tab": "重命名标签页",
"Close tab": "关闭标签页",
"Tab name": "标签页名称",
Expand Down