diff --git a/packages/app/e2e/actions.ts b/packages/app/e2e/actions.ts index a7ccba61752..4d18e6d1785 100644 --- a/packages/app/e2e/actions.ts +++ b/packages/app/e2e/actions.ts @@ -17,6 +17,7 @@ import { listItemKeyStartsWithSelector, workspaceItemSelector, workspaceMenuTriggerSelector, + workspacePinToggleSelector, } from "./selectors" import type { createSdk } from "./utils" @@ -576,3 +577,16 @@ export async function openWorkspaceMenu(page: Page, workspaceSlug: string) { await expect(menu).toBeVisible() return menu } + +export async function setWorkspacePinned(page: Page, workspaceSlug: string, enabled: boolean) { + const menu = await openWorkspaceMenu(page, workspaceSlug) + const toggle = menu.locator(workspacePinToggleSelector(workspaceSlug)).first() + await expect(toggle).toBeVisible() + const name = await toggle.textContent() + const pinned = (name ?? "").toLowerCase().includes("unpin") + if (pinned === enabled) { + await page.keyboard.press("Escape") + return + } + await toggle.click({ force: true }) +} diff --git a/packages/app/e2e/projects/workspaces.spec.ts b/packages/app/e2e/projects/workspaces.spec.ts index 3867395267b..2d90a845994 100644 --- a/packages/app/e2e/projects/workspaces.spec.ts +++ b/packages/app/e2e/projects/workspaces.spec.ts @@ -8,20 +8,43 @@ import { test, expect } from "../fixtures" test.describe.configure({ mode: "serial" }) import { + createTestProject, cleanupTestProject, clickMenuItem, confirmDialog, openSidebar, openWorkspaceMenu, + setWorkspacePinned, setWorkspacesEnabled, } from "../actions" -import { dropdownMenuContentSelector, inlineInputSelector, workspaceItemSelector } from "../selectors" +import { + dropdownMenuContentSelector, + inlineInputSelector, + projectSwitchSelector, + workspaceDividerSelector, + workspaceItemSelector, +} from "../selectors" import { createSdk, dirSlug } from "../utils" function slugFromUrl(url: string) { return /\/([^/]+)\/session(?:\/|$)/.exec(url)?.[1] ?? "" } +async function ensureWorkspacesEnabled(page: Page, slug: string) { + for (const _ of [0, 1, 2]) { + await openSidebar(page) + await setWorkspacesEnabled(page, slug, true) + const visible = await page + .getByRole("button", { name: "New workspace" }) + .first() + .isVisible() + .then((x) => x) + .catch(() => false) + if (visible) return + } + await expect(page.getByRole("button", { name: "New workspace" }).first()).toBeVisible({ timeout: 60_000 }) +} + async function setupWorkspaceTest(page: Page, project: { slug: string }) { const rootSlug = project.slug await openSidebar(page) @@ -302,6 +325,257 @@ test("can delete a workspace", async ({ page, withProject }) => { }) }) +test("can pin and unpin a workspace with persistence", async ({ page, withProject }) => { + await page.setViewportSize({ width: 1400, height: 800 }) + await withProject(async ({ slug: rootSlug }) => { + await ensureWorkspacesEnabled(page, rootSlug) + + const workspaces = [] as string[] + for (const _ of [0, 1]) { + const prev = slugFromUrl(page.url()) + await page.getByRole("button", { name: "New workspace" }).first().click() + await expect + .poll( + () => { + const slug = slugFromUrl(page.url()) + return slug.length > 0 && slug !== rootSlug && slug !== prev + }, + { timeout: 45_000 }, + ) + .toBe(true) + + workspaces.push(slugFromUrl(page.url())) + await openSidebar(page) + } + + const a = workspaces[0] + const b = workspaces[1] + if (!a || !b) throw new Error("Expected two created workspaces") + + const key = (slug: string) => { + return base64Decode(slug) + .replace(/[\\/]+/g, "/") + .replace(/\/+$/, "") + .toLowerCase() + } + + const aKey = key(a) + const bKey = key(b) + const rootKey = key(rootSlug) + + const list = async () => { + const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]') + const slugs = await nodes.evaluateAll((els) => { + return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0) + }) + const seen = new Set() + return slugs + .filter((slug) => { + const slugKey = key(slug) + if (seen.has(slugKey)) return false + seen.add(slugKey) + return true + }) + .filter((slug) => { + const slugKey = key(slug) + return slugKey === aKey || slugKey === bKey + }) + } + + const listAll = async () => { + const nodes = page.locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]') + const slugs = await nodes.evaluateAll((els) => { + return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0) + }) + const seen = new Set() + return slugs + .filter((slug) => { + const slugKey = key(slug) + if (seen.has(slugKey)) return false + seen.add(slugKey) + return true + }) + .filter((slug) => { + const slugKey = key(slug) + return slugKey === rootKey || slugKey === aKey || slugKey === bKey + }) + } + + const find = async (target: string) => { + const slugs = await listAll() + return slugs.find((slug) => key(slug) === target) + } + + await expect.poll(async () => (await list()).length).toBe(2) + const before = await list() + const aSlug = await find(aKey) + if (!aSlug) throw new Error("Missing first workspace slug") + + await setWorkspacePinned(page, aSlug, true) + await expect.poll(async () => (await list()).map((slug) => key(slug))).toEqual([aKey, bKey]) + + await setWorkspacePinned(page, rootSlug, false) + await expect.poll(async () => key((await listAll())[0] ?? "")).toBe(aKey) + + await setWorkspacePinned(page, rootSlug, true) + await expect.poll(async () => key((await listAll())[0] ?? "")).toBe(rootKey) + + await page.reload() + await openSidebar(page) + await expect.poll(async () => (await list()).map((slug) => key(slug))).toEqual([aKey, bKey]) + + const pinnedSlug = await find(aKey) + if (!pinnedSlug) throw new Error("Missing pinned workspace slug") + await setWorkspacePinned(page, pinnedSlug, false) + await expect.poll(async () => await list()).toEqual(before) + }) +}) + +test("workspace pinning is isolated per project", async ({ page, withProject }) => { + await page.setViewportSize({ width: 1400, height: 800 }) + + const other = await createTestProject() + const otherSlug = dirSlug(other) + const dirs = [] as string[] + const key = (slug: string) => + base64Decode(slug) + .replace(/[\\/]+/g, "/") + .replace(/\/+$/, "") + .toLowerCase() + + try { + await withProject( + async ({ slug }) => { + await ensureWorkspacesEnabled(page, slug) + + await page.getByRole("button", { name: "New workspace" }).first().click() + await expect + .poll( + () => { + const next = slugFromUrl(page.url()) + if (!next) return "" + if (next === slug) return "" + return next + }, + { timeout: 45_000 }, + ) + .not.toBe("") + + const pinnedSlug = slugFromUrl(page.url()) + dirs.push(base64Decode(pinnedSlug)) + const pinnedKey = key(pinnedSlug) + + await openSidebar(page) + await setWorkspacePinned(page, pinnedSlug, true) + + const pinnedMenu = await openWorkspaceMenu(page, pinnedSlug) + await expect( + pinnedMenu + .getByRole("menuitem") + .filter({ hasText: /^Unpin$/i }) + .first(), + ).toBeVisible() + await page.keyboard.press("Escape") + + const otherButton = page.locator(projectSwitchSelector(otherSlug)).first() + await expect(otherButton).toBeVisible() + await otherButton.click() + await expect(page).toHaveURL(new RegExp(`/${otherSlug}/session`)) + + await ensureWorkspacesEnabled(page, otherSlug) + + await page.getByRole("button", { name: "New workspace" }).first().click() + await expect + .poll( + () => { + const next = slugFromUrl(page.url()) + if (!next) return "" + if (next === otherSlug) return "" + return next + }, + { timeout: 45_000 }, + ) + .not.toBe("") + + const otherWorkspace = slugFromUrl(page.url()) + dirs.push(base64Decode(otherWorkspace)) + + await openSidebar(page) + const otherMenu = await openWorkspaceMenu(page, otherWorkspace) + await expect(otherMenu.getByRole("menuitem").filter({ hasText: /^Pin$/i }).first()).toBeVisible() + await page.keyboard.press("Escape") + + const rootButton = page.locator(projectSwitchSelector(slug)).first() + await expect(rootButton).toBeVisible() + await rootButton.click() + + await openSidebar(page) + const slugs = await page + .locator('[data-component="sidebar-nav-desktop"] [data-component="workspace-item"]') + .evaluateAll((els) => { + return els.map((el) => el.getAttribute("data-workspace") ?? "").filter((x) => x.length > 0) + }) + const rootSlug = slugs.find((slug) => key(slug) === pinnedKey) + if (!rootSlug) throw new Error("Could not find pinned workspace in original project") + + const rootMenu = await openWorkspaceMenu(page, rootSlug) + await expect( + rootMenu + .getByRole("menuitem") + .filter({ hasText: /^Unpin$/i }) + .first(), + ).toBeVisible() + }, + { extra: [other] }, + ) + } finally { + await Promise.all(dirs.map((dir) => cleanupTestProject(dir))) + await cleanupTestProject(other) + } +}) + +test("workspace divider is shown only with mixed pin state", async ({ page, withProject }) => { + await page.setViewportSize({ width: 1400, height: 800 }) + + await withProject(async ({ slug: rootSlug }) => { + await ensureWorkspacesEnabled(page, rootSlug) + + const workspaces = [] as string[] + try { + for (const _ of [0, 1]) { + const prev = slugFromUrl(page.url()) + await page.getByRole("button", { name: "New workspace" }).first().click() + await expect + .poll( + () => { + const slug = slugFromUrl(page.url()) + return slug.length > 0 && slug !== rootSlug && slug !== prev + }, + { timeout: 45_000 }, + ) + .toBe(true) + + workspaces.push(slugFromUrl(page.url())) + await openSidebar(page) + } + + const a = workspaces[0] + const b = workspaces[1] + if (!a || !b) throw new Error("Expected two created workspaces") + + await setWorkspacePinned(page, rootSlug, false) + await setWorkspacePinned(page, a, true) + await setWorkspacePinned(page, b, false) + await expect.poll(async () => await page.locator(workspaceDividerSelector).count()).toBeGreaterThan(0) + + await setWorkspacePinned(page, a, false) + await expect.poll(async () => await page.locator(workspaceDividerSelector).count()).toBe(0) + } finally { + await Promise.all(workspaces.map((slug) => cleanupTestProject(base64Decode(slug)))) + } + }) +}) + test("can reorder workspaces by drag and drop", async ({ page, withProject }) => { await page.setViewportSize({ width: 1400, height: 800 }) await withProject(async ({ slug: rootSlug }) => { diff --git a/packages/app/e2e/selectors.ts b/packages/app/e2e/selectors.ts index 5fad2c06b52..bd3d9afd532 100644 --- a/packages/app/e2e/selectors.ts +++ b/packages/app/e2e/selectors.ts @@ -58,9 +58,14 @@ export const sessionItemSelector = (sessionID: string) => `${sidebarNavSelector} export const workspaceItemSelector = (slug: string) => `${sidebarNavSelector} [data-component="workspace-item"][data-workspace="${slug}"]` +export const workspaceDividerSelector = `${sidebarNavSelector} [data-component="workspace-item"][data-workspace-divider="true"]` + export const workspaceMenuTriggerSelector = (slug: string) => `${sidebarNavSelector} [data-action="workspace-menu"][data-workspace="${slug}"]` +export const workspacePinToggleSelector = (slug: string) => + `[data-action="workspace-pin-toggle"][data-workspace="${slug}"]` + export const workspaceNewSessionSelector = (slug: string) => `${sidebarNavSelector} [data-action="workspace-new-session"][data-workspace="${slug}"]` diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 7e95fd739df..1db305a7ccf 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -820,6 +820,8 @@ export const dict = { "session.delete.button": "Delete session", "workspace.new": "New workspace", + "workspace.pin": "Pin", + "workspace.unpin": "Unpin", "workspace.type.local": "local", "workspace.type.sandbox": "sandbox", "workspace.create.failed.title": "Failed to create workspace", diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 2019ca4e5a8..392df91f4d4 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -61,6 +61,7 @@ import { useLanguage, type Locale } from "@/context/language" import { childMapByParent, displayName, + effectiveWorkspacePinnedOrder, effectiveWorkspaceOrder, errorMessage, getDraggableId, @@ -93,6 +94,7 @@ export default function Layout(props: ParentProps) { activeProject: undefined as string | undefined, activeWorkspace: undefined as string | undefined, workspaceOrder: {} as Record, + workspacePinned: {} as Record, workspaceName: {} as Record, workspaceBranchName: {} as Record>, workspaceExpanded: {} as Record, @@ -1057,6 +1059,11 @@ export default function Layout(props: ParentProps) { ) if (known) return known[0] + const knownPinned = Object.entries(store.workspacePinned).find( + ([root, dirs]) => root === directory || dirs.includes(directory), + ) + if (knownPinned) return knownPinned[0] + const [child] = globalSync.child(directory, { bootstrap: false }) const id = child.project if (!id) return directory @@ -1231,6 +1238,20 @@ export default function Layout(props: ParentProps) { setWorkspaceName(directory, next, projectId, branch) } + const workspacePinned = (root: string, directory: string) => { + const key = workspaceKey(directory) + return (store.workspacePinned[root] ?? []).some((item) => workspaceKey(item) === key) + } + + const setWorkspacePinned = (root: string, directory: string, value: boolean) => { + const key = workspaceKey(directory) + setStore("workspacePinned", root, (prev) => { + const next = (prev ?? []).filter((item) => workspaceKey(item) !== key) + if (!value) return next + return [directory, ...next] + }) + } + function closeProject(directory: string) { const list = layout.projects.list() const index = list.findIndex((x) => x.worktree === directory) @@ -1334,7 +1355,9 @@ export default function Layout(props: ParentProps) { project.sandboxes = (project.sandboxes ?? []).filter((sandbox) => sandbox !== directory) }), ) - setStore("workspaceOrder", root, (order) => (order ?? []).filter((workspace) => workspace !== directory)) + const key = workspaceKey(directory) + setStore("workspaceOrder", root, (order) => (order ?? []).filter((workspace) => workspaceKey(workspace) !== key)) + setStore("workspacePinned", root, (pinned) => (pinned ?? []).filter((workspace) => workspaceKey(workspace) !== key)) layout.projects.close(directory) layout.projects.open(root) @@ -1668,7 +1691,12 @@ export default function Layout(props: ParentProps) { const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false - const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[project.worktree]) + const ordered = effectiveWorkspacePinnedOrder( + local, + dirs, + store.workspaceOrder[project.worktree], + store.workspacePinned[project.worktree], + ) if (pending && extra) return [local, extra, ...ordered.filter((item) => item !== local)] if (!extra) return ordered if (pending) return ordered @@ -1701,6 +1729,11 @@ export default function Layout(props: ParentProps) { if (fromIndex === -1 || toIndex === -1) return if (fromIndex === toIndex) return + const from = ids[fromIndex] + const to = ids[toIndex] + if (!from || !to) return + if (workspacePinned(project.worktree, from) !== workspacePinned(project.worktree, to)) return + const result = ids.slice() const [item] = result.splice(fromIndex, 1) if (!item) return @@ -1780,6 +1813,8 @@ export default function Layout(props: ParentProps) { dialog.show(() => ), showDeleteWorkspaceDialog: (root, directory) => dialog.show(() => ), + workspacePinned, + setWorkspacePinned, setScrollContainerRef: (el, mobile) => { if (!mobile) scrollContainerRef = el }, @@ -1823,6 +1858,14 @@ export default function Layout(props: ParentProps) { }) const projectId = createMemo(() => panelProps.project?.id ?? "") const workspaces = createMemo(() => workspaceIds(panelProps.project)) + const firstUnpinned = createMemo(() => { + const project = panelProps.project + if (!project) return + const list = workspaces() + const split = list.findIndex((directory) => !workspacePinned(project.worktree, directory)) + if (split <= 0) return + return list[split] + }) const unseenCount = createMemo(() => workspaces().reduce((total, directory) => total + notification.project.unseenCount(directory), 0), ) @@ -1998,6 +2041,7 @@ export default function Layout(props: ParentProps) { directory={directory} project={p} sortNow={sortNow} + divider={directory === firstUnpinned()} mobile={panelProps.mobile} /> )} diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index 42315e5893c..8ee3e4f330d 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -99,4 +99,29 @@ export const effectiveWorkspaceOrder = (local: string, dirs: string[], persisted return [...result, ...live.values()] } +export const effectiveWorkspacePinnedOrder = ( + local: string, + dirs: string[], + persisted?: string[], + pinned?: string[], +) => { + const ordered = effectiveWorkspaceOrder(local, dirs, persisted) + if (!pinned?.length) return ordered + + const set = new Set(pinned.map((dir) => workspaceKey(dir))) + if (set.size === 0) return ordered + + const pinnedDirs = [] as string[] + const rest = [] as string[] + for (const dir of ordered) { + if (set.has(workspaceKey(dir))) { + pinnedDirs.push(dir) + continue + } + rest.push(dir) + } + + return [...pinnedDirs, ...rest] +} + export const syncWorkspaceOrder = effectiveWorkspaceOrder diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index 43d99cf8954..d3ada2e6024 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -52,6 +52,8 @@ export type WorkspaceSidebarContext = { setWorkspaceExpanded: (directory: string, value: boolean) => void showResetWorkspaceDialog: (root: string, directory: string) => void showDeleteWorkspaceDialog: (root: string, directory: string) => void + workspacePinned: (root: string, directory: string) => boolean + setWorkspacePinned: (root: string, directory: string, value: boolean) => void setScrollContainerRef: (el: HTMLDivElement | undefined, mobile?: boolean) => void } @@ -152,6 +154,8 @@ const WorkspaceActions = (props: { openEditor: WorkspaceSidebarContext["openEditor"] showResetWorkspaceDialog: WorkspaceSidebarContext["showResetWorkspaceDialog"] showDeleteWorkspaceDialog: WorkspaceSidebarContext["showDeleteWorkspaceDialog"] + pinned: Accessor + setWorkspacePinned: WorkspaceSidebarContext["setWorkspacePinned"] root: string setHoverSession: WorkspaceSidebarContext["setHoverSession"] clearHoverProjectSoon: WorkspaceSidebarContext["clearHoverProjectSoon"] @@ -200,6 +204,15 @@ const WorkspaceActions = (props: { > {props.language.t("common.rename")} + props.setWorkspacePinned(props.root, props.directory, !props.pinned())} + > + + {props.pinned() ? props.language.t("workspace.unpin") : props.language.t("workspace.pin")} + + props.showResetWorkspaceDialog(props.root, props.directory)} @@ -303,6 +316,7 @@ export const SortableWorkspace = (props: { directory: string project: LocalProject sortNow: Accessor + divider?: boolean mobile?: boolean }): JSX.Element => { const navigate = useNavigate() @@ -330,6 +344,7 @@ export const SortableWorkspace = (props: { const booted = createMemo((prev) => prev || workspaceStore.status === "complete", false) const hasMore = createMemo(() => workspaceStore.sessionTotal > sessions().length) const busy = createMemo(() => props.ctx.isBusy(props.directory)) + const pinned = createMemo(() => props.ctx.workspacePinned(props.project.worktree, props.directory)) const wasBusy = createMemo((prev) => prev || busy(), false) const loading = createMemo(() => open() && !booted() && sessions().length === 0 && !wasBusy()) const touch = createMediaQuery("(hover: none)") @@ -359,6 +374,7 @@ export const SortableWorkspace = (props: { classList={{ "opacity-30": sortable.isActiveDraggable, "opacity-50 pointer-events-none": busy(), + "pt-3 mt-1 border-t border-border-weak-base": !!props.divider, }} > @@ -367,6 +383,7 @@ export const SortableWorkspace = (props: { class="group/workspace relative" data-component="workspace-item" data-workspace={base64Encode(props.directory)} + data-workspace-divider={props.divider ? "true" : undefined} >