mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-25 14:00:40 +00:00
feat(web): "+" finds a workspace outside your tabs, creates one, or restores one (TASK-3276) (#1639)
PLAN-3002 U4, Dave's Q4 ruling. Since U3 the bar shows only the open set, so the TopBar "+" becomes the way to reach any other workspace: - WorkspaceDiscovery: a combobox over a listbox of every visible workspace NOT open as a tab, filtered by name or slug, then "New workspace", then recently deleted. Arrows (wrapping, aria-activedescendant), Enter, Escape (returns focus to "+"), and outside press. Choosing a workspace opens it as a KEPT tab and lands on its last route; a failed open still navigates. - RecentlyDeletedWorkspaces: the deleted-list piece moved out of WorkspaceSwitcher, which now renders it too (U6 will build on the same). - The "+" title is now "Find or create a workspace", since it no longer opens the create modal directly; two e2e specs follow the new path. - Pending invitations are split out to U4b (TASK-3277); "+" keeps a slot. Also: the U3 drag e2e leg released before svelte-dnd-action sampled the final pointer position (1 in 10); it now waits for the drag to engage and for the shadow slot to move before releasing. Claude-Session: https://claude.ai/code/session_016PXRzXhokr61CLbbu3Txj9
This commit is contained in:
@@ -168,7 +168,9 @@ test('workspace export → import round-trip via web UI', async ({ page, fixture
|
||||
await writeFile(bundlePath, bundleBytes);
|
||||
|
||||
// 3. Open the Create Workspace modal and switch to Import.
|
||||
// On desktop the TopBar exposes a "+" button (title="New workspace");
|
||||
// On desktop the TopBar exposes a "+" button (title="Find or create a
|
||||
// workspace", PLAN-3002 U4) whose discovery surface has a "New workspace…"
|
||||
// option;
|
||||
// on mobile that button is hidden behind the layout's collapse.
|
||||
// Try the TopBar trigger first and fall back to opening the
|
||||
// WorkspaceSwitcher dropdown/sheet and clicking its
|
||||
@@ -188,9 +190,10 @@ test('workspace export → import round-trip via web UI', async ({ page, fixture
|
||||
// rendered by the dashboard route the moment hydration completes.
|
||||
await expect(page.getByRole('heading', { name: /E2E Workspace/i })).toBeVisible();
|
||||
|
||||
const topbarTrigger = page.getByTitle('New workspace');
|
||||
const topbarTrigger = page.getByTitle('Find or create a workspace');
|
||||
if (await topbarTrigger.first().isVisible().catch(() => false)) {
|
||||
await topbarTrigger.first().click();
|
||||
await page.getByRole('option', { name: /new workspace/i }).click();
|
||||
} else {
|
||||
// Mobile path: tap the workspace switcher's current-workspace
|
||||
// button to open the picker, then click "+ New Workspace".
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { test, expect, type SuiteFixture } from './fixtures';
|
||||
import { request, type APIRequestContext, type BrowserContext, type Page } from '@playwright/test';
|
||||
import { request, type APIRequestContext, type BrowserContext, type Locator, type Page } from '@playwright/test';
|
||||
import { quietCrossActorToasts } from './fixtures';
|
||||
|
||||
/**
|
||||
@@ -103,6 +103,18 @@ async function barOrder(page: Page): Promise<string[]> {
|
||||
return tabs(page).evaluateAll((els) => els.map((el) => (el as HTMLElement).dataset.wsSlug ?? ''));
|
||||
}
|
||||
|
||||
/** A locator's box once two reads 100 ms apart agree (an animation has settled). */
|
||||
async function stableBox(locator: Locator) {
|
||||
let prev = await locator.boundingBox();
|
||||
for (let i = 0; i < 30; i++) {
|
||||
await locator.page().waitForTimeout(100);
|
||||
const next = await locator.boundingBox();
|
||||
if (prev && next && prev.x === next.x && prev.y === next.y && prev.width === next.width) return next;
|
||||
prev = next;
|
||||
}
|
||||
throw new Error('box never settled');
|
||||
}
|
||||
|
||||
async function closeTab(page: Page, slug: string) {
|
||||
await tab(page, slug).hover();
|
||||
await tab(page, slug).locator('.workspace-tab-close').click();
|
||||
@@ -152,15 +164,25 @@ test.describe('workspace tab bar (TASK-3274)', () => {
|
||||
await expect(tabs(page)).toHaveCount(2);
|
||||
await expect(page).toHaveURL(new RegExp(`/${a}$`));
|
||||
|
||||
// Drag B in front of A, with a real mouse.
|
||||
const from = await tab(page, b).locator('a').boundingBox();
|
||||
const to = await tab(page, a).locator('a').boundingBox();
|
||||
if (!from || !to) throw new Error('tab has no box');
|
||||
// Drag B in front of A, with a real mouse. The close just reflowed
|
||||
// the zone with a 150 ms FLIP animation, so the boxes are read only
|
||||
// once they hold still: a box read mid-animation aims the drag at
|
||||
// where a tab was, and the drop lands nowhere.
|
||||
const from = await stableBox(tab(page, b).locator('a'));
|
||||
const to = await stableBox(tab(page, a).locator('a'));
|
||||
await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(from.x + from.width / 2 - 10, from.y + from.height / 2, { steps: 4 });
|
||||
// The drag has engaged once svelte-dnd-action mounts its clone.
|
||||
await expect(page.locator('#dnd-action-dragged-el')).toHaveCount(1);
|
||||
await page.mouse.move(to.x + 4, to.y + to.height / 2, { steps: 10 });
|
||||
// svelte-dnd-action samples the pointer on an interval, so a
|
||||
// release straight after the last move can drop before it has seen
|
||||
// where the pointer is. Hold over the target until the shadow slot
|
||||
// has moved in front of A, then release.
|
||||
await expect.poll(() => barOrder(page)).toEqual([b, a]);
|
||||
await page.mouse.up();
|
||||
await expect(page.locator('#dnd-action-dragged-el')).toHaveCount(0);
|
||||
await expect.poll(() => barOrder(page)).toEqual([b, a]);
|
||||
await expect.poll(() => serverOrder(account)).toEqual([b, a]);
|
||||
// The drop's synthetic click must not have navigated to B.
|
||||
@@ -278,7 +300,8 @@ test.describe('workspace tab bar (TASK-3274)', () => {
|
||||
|
||||
await page.goto(`/${account.username}/${home}`);
|
||||
await expect(tabs(page)).toHaveCount(1);
|
||||
await page.getByTitle('New workspace').click();
|
||||
await page.getByTitle('Find or create a workspace').click();
|
||||
await page.getByRole('option', { name: /new workspace/i }).click();
|
||||
const dialog = page.getByRole('dialog', { name: /new workspace/i });
|
||||
await expect(dialog).toBeVisible();
|
||||
const name = `Made here ${Date.now()}`;
|
||||
@@ -299,3 +322,97 @@ test.describe('workspace tab bar (TASK-3274)', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* TASK-3276 (PLAN-3002 U4): the "+" discovery surface. Since U3 the bar shows
|
||||
* only the open set, so "+" is how a workspace outside it is reached. The
|
||||
* create path is covered above ('a workspace created with "+" …').
|
||||
*/
|
||||
test.describe('"+" discovery surface (TASK-3276)', () => {
|
||||
test.beforeEach(async ({ page }, testInfo) => {
|
||||
test.skip(testInfo.project.name !== 'desktop-chromium', 'the tab bar is desktop-only (PLAN-3002 Q11)');
|
||||
await page.setViewportSize(DESKTOP);
|
||||
});
|
||||
|
||||
test('a user with seven workspaces and six tabs reaches the seventh from "+", as a kept tab', async ({
|
||||
page,
|
||||
context,
|
||||
fixture
|
||||
}) => {
|
||||
let world: World | undefined;
|
||||
try {
|
||||
world = await seed(fixture, ['One', 'Two', 'Three', 'Four', 'Five', 'Six', 'Seventh']);
|
||||
const { account, slugs } = world;
|
||||
const seventh = slugs[6];
|
||||
await setOpenSet(account, slugs.slice(0, 6));
|
||||
await actAs(context, account);
|
||||
|
||||
await page.goto(`/${account.username}/${slugs[0]}`);
|
||||
await expect(tabs(page)).toHaveCount(6);
|
||||
await expect(tab(page, seventh)).toHaveCount(0);
|
||||
|
||||
await page.getByTitle('Find or create a workspace').click();
|
||||
const search = page.getByRole('combobox', { name: 'Find a workspace' });
|
||||
await expect(search).toBeFocused();
|
||||
// The six open workspaces are not offered; the seventh is.
|
||||
await expect(page.locator('.discovery-option[data-ws-slug]')).toHaveCount(1);
|
||||
await search.fill('Seventh');
|
||||
await page.locator(`.discovery-option[data-ws-slug="${seventh}"]`).click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${account.username}/${seventh}$`));
|
||||
await expect(tabs(page)).toHaveCount(7);
|
||||
await expect(tab(page, seventh)).not.toHaveClass(/ephemeral/);
|
||||
await expect.poll(() => serverOrder(account)).toContain(seventh);
|
||||
|
||||
await page.reload();
|
||||
await expect(tab(page, seventh)).toHaveCount(1);
|
||||
await expect(tab(page, seventh)).not.toHaveClass(/ephemeral/);
|
||||
} finally {
|
||||
await teardown(world);
|
||||
}
|
||||
});
|
||||
|
||||
test('keyboard only: open "+", search, arrow, Enter; and Escape returns focus to "+"', async ({
|
||||
page,
|
||||
context,
|
||||
fixture
|
||||
}) => {
|
||||
let world: World | undefined;
|
||||
try {
|
||||
world = await seed(fixture, ['Home', 'Kb Alpha', 'Kb Beta']);
|
||||
const { account, slugs } = world;
|
||||
const [home, , beta] = slugs;
|
||||
await setOpenSet(account, [home]);
|
||||
await actAs(context, account);
|
||||
|
||||
await page.goto(`/${account.username}/${home}`);
|
||||
await expect(tabs(page)).toHaveCount(1);
|
||||
const plus = page.getByTitle('Find or create a workspace');
|
||||
const search = page.getByRole('combobox', { name: 'Find a workspace' });
|
||||
|
||||
// Escape closes and hands focus back to "+".
|
||||
await plus.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(search).toBeFocused();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(search).toHaveCount(0);
|
||||
await expect(plus).toBeFocused();
|
||||
|
||||
// Open again from the keyboard, narrow to the two "Kb" workspaces,
|
||||
// step to the second, and take it.
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(search).toBeFocused();
|
||||
await page.keyboard.type('Kb');
|
||||
await expect(page.locator('.discovery-option[data-ws-slug]')).toHaveCount(2);
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await expect(page.locator('.discovery-option[aria-selected="true"]')).toHaveAttribute('data-ws-slug', beta);
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/${account.username}/${beta}$`));
|
||||
await expect(tab(page, beta)).toHaveCount(1);
|
||||
await expect(tab(page, beta)).not.toHaveClass(/ephemeral/);
|
||||
} finally {
|
||||
await teardown(world);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
<script lang="ts">
|
||||
// Soft-deleted workspaces still inside the restore window (TASK-1974),
|
||||
// with a Restore action per row. Moved out of WorkspaceSwitcher so the
|
||||
// TopBar "+" discovery surface (PLAN-3002 U4 / TASK-3276) and the mobile
|
||||
// switcher render the same piece instead of two copies.
|
||||
//
|
||||
// Renders NOTHING when the list is empty or the fetch failed, so it never
|
||||
// adds noise to a surface with nothing to restore.
|
||||
import { api } from '$lib/api/client';
|
||||
import { workspaceStore } from '$lib/stores/workspace.svelte';
|
||||
import { toastStore } from '$lib/stores/toast.svelte';
|
||||
import type { DeletedWorkspace } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
/** Re-fetched each time this turns true (the host surface opened). */
|
||||
active: boolean;
|
||||
/** Roomier rows for a mobile sheet. */
|
||||
roomy?: boolean;
|
||||
}
|
||||
|
||||
let { active, roomy = false }: Props = $props();
|
||||
|
||||
let deleted = $state<DeletedWorkspace[]>([]);
|
||||
// Collapsed by default; kept while the host stays open.
|
||||
let expanded = $state(false);
|
||||
// Slug being restored: per-row pending state, so a double-click cannot
|
||||
// fire two restores.
|
||||
let restoringSlug = $state<string | null>(null);
|
||||
// Monotonic token guarding listDeleted() responses: an older fetch cannot
|
||||
// clobber the fresher post-restore refresh and re-surface a restored
|
||||
// workspace.
|
||||
let deletedSeq = 0;
|
||||
|
||||
async function loadDeleted() {
|
||||
const seq = ++deletedSeq;
|
||||
try {
|
||||
const list = await api.workspaces.listDeleted();
|
||||
if (seq === deletedSeq) deleted = list;
|
||||
} catch {
|
||||
// The restore affordance is a bonus, never a blocker. Clear so a
|
||||
// stale list does not linger and the section stays hidden.
|
||||
if (seq === deletedSeq) deleted = [];
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (active) loadDeleted();
|
||||
});
|
||||
|
||||
async function restore(ws: DeletedWorkspace) {
|
||||
if (restoringSlug) return;
|
||||
restoringSlug = ws.slug;
|
||||
try {
|
||||
try {
|
||||
await api.workspaces.restore(ws.slug);
|
||||
} catch {
|
||||
// Only the restore call itself failing is a restore failure.
|
||||
toastStore.show(`Couldn't restore "${ws.name}"`, 'error');
|
||||
return;
|
||||
}
|
||||
// Confirmed before refreshing, so a failing reload cannot pass for
|
||||
// a restore failure.
|
||||
toastStore.show(`Restored "${ws.name}"`, 'success');
|
||||
try {
|
||||
await Promise.all([loadDeleted(), workspaceStore.loadAll()]);
|
||||
} catch {
|
||||
// Reload failure is non-fatal; the restore went through.
|
||||
}
|
||||
} finally {
|
||||
restoringSlug = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if deleted.length > 0}
|
||||
<div class="deleted-section" class:roomy>
|
||||
<button
|
||||
type="button"
|
||||
class="deleted-header"
|
||||
onclick={() => (expanded = !expanded)}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
<span class="deleted-caret" aria-hidden="true">{expanded ? '▾' : '▸'}</span>
|
||||
<span class="deleted-title">Recently deleted</span>
|
||||
<span class="deleted-count">{deleted.length}</span>
|
||||
</button>
|
||||
{#if expanded}
|
||||
<div class="deleted-list">
|
||||
{#each deleted as ws (ws.slug)}
|
||||
<div class="deleted-item">
|
||||
<span class="deleted-name" title={ws.name}>{ws.name}</span>
|
||||
<span class="deleted-days">
|
||||
{ws.days_left} {ws.days_left === 1 ? 'day' : 'days'} left
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="restore-btn"
|
||||
onclick={() => restore(ws)}
|
||||
disabled={restoringSlug === ws.slug}
|
||||
>
|
||||
{restoringSlug === ws.slug ? 'Restoring…' : 'Restore'}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.deleted-section { border-top: 1px solid var(--border); }
|
||||
.deleted-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.deleted-header:hover { background: var(--bg-hover); }
|
||||
.deleted-caret { font-size: 0.9em; flex-shrink: 0; }
|
||||
.deleted-title { flex: 1; min-width: 0; }
|
||||
.deleted-count {
|
||||
flex-shrink: 0;
|
||||
padding: 0 var(--space-2);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.deleted-list { display: flex; flex-direction: column; }
|
||||
.deleted-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.roomy .deleted-header { padding: var(--space-3); font-size: 0.85em; }
|
||||
.roomy .deleted-item { padding: var(--space-2) var(--space-3); }
|
||||
.deleted-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.deleted-days {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.restore-btn {
|
||||
flex-shrink: 0;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--accent-blue);
|
||||
cursor: pointer;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.restore-btn:hover:not(:disabled) { background: var(--bg-hover); }
|
||||
.restore-btn:disabled { opacity: 0.6; cursor: default; }
|
||||
</style>
|
||||
@@ -13,6 +13,7 @@
|
||||
import WorkspaceSwitcher from '$lib/components/layout/WorkspaceSwitcher.svelte';
|
||||
import UserMenuResources from '$lib/components/layout/UserMenuResources.svelte';
|
||||
import ConnectWorkspaceModal from '$lib/components/ConnectWorkspaceModal.svelte';
|
||||
import WorkspaceDiscovery from '$lib/components/layout/WorkspaceDiscovery.svelte';
|
||||
import Menu from '$lib/components/common/Menu.svelte';
|
||||
import MenuItem from '$lib/components/common/MenuItem.svelte';
|
||||
import { workspaceRestoreTarget } from '$lib/utils/workspace-route';
|
||||
@@ -23,6 +24,10 @@
|
||||
let userTriggerEl: HTMLButtonElement | undefined = $state(undefined);
|
||||
let currentTheme = $state<'dark' | 'light'>('dark');
|
||||
let connectOpen = $state(false);
|
||||
// The "+" discovery surface (PLAN-3002 U4): find a workspace that is not
|
||||
// open as a tab, create one, or restore a deleted one.
|
||||
let discoveryOpen = $state(false);
|
||||
let addEl: HTMLButtonElement | undefined = $state(undefined);
|
||||
|
||||
let currentSlug = $derived(workspaceStore.current?.slug ?? '');
|
||||
|
||||
@@ -407,13 +412,20 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<button
|
||||
class="workspace-add"
|
||||
onclick={() => uiStore.openCreateWorkspace()}
|
||||
title="New workspace"
|
||||
>
|
||||
<span class="add-icon">+</span>
|
||||
</button>
|
||||
<div class="workspace-add-anchor">
|
||||
<button
|
||||
class="workspace-add"
|
||||
bind:this={addEl}
|
||||
onclick={() => (discoveryOpen = !discoveryOpen)}
|
||||
title="Find or create a workspace"
|
||||
aria-label="Find or create a workspace"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={discoveryOpen}
|
||||
>
|
||||
<span class="add-icon">+</span>
|
||||
</button>
|
||||
<WorkspaceDiscovery open={discoveryOpen} onclose={() => (discoveryOpen = false)} trigger={addEl} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="topbar-right">
|
||||
@@ -676,6 +688,10 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.workspace-add-anchor {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.workspace-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -32,7 +32,7 @@ vi.mock('$app/navigation', () => ({ goto: mocks.goto }));
|
||||
|
||||
vi.mock('$lib/api/client', () => ({
|
||||
PadApiError: class extends Error {},
|
||||
api: { workspaces: { tabs: mocks.tabs } },
|
||||
api: { workspaces: { tabs: mocks.tabs, listDeleted: vi.fn(async () => []) } },
|
||||
}));
|
||||
|
||||
vi.mock('$lib/stores/workspace.svelte', () => ({
|
||||
@@ -239,8 +239,11 @@ describe('TopBar: the Q2 highlight', () => {
|
||||
it('opening the create flow from "+" clears it', async () => {
|
||||
await mountWith([ALPHA]);
|
||||
uiStore.highlightAddWorkspace();
|
||||
const add = document.querySelector<HTMLButtonElement>('.workspace-add')!;
|
||||
add.click();
|
||||
document.querySelector<HTMLButtonElement>('.workspace-add')!.click();
|
||||
await tick();
|
||||
flushSync();
|
||||
// "+" opens the discovery surface (U4); its last option is create.
|
||||
document.querySelector<HTMLElement>('.discovery-option.create')!.click();
|
||||
flushSync();
|
||||
expect(uiStore.addWorkspaceHighlighted).toBe(false);
|
||||
uiStore.closeCreateWorkspace();
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
<script lang="ts">
|
||||
// The TopBar "+" discovery surface (PLAN-3002 U4 / TASK-3276, Dave's Q4
|
||||
// ruling): search the workspaces that are NOT open as tabs, create one,
|
||||
// and restore a recently deleted one. Since U3 the bar shows only the open
|
||||
// set, so this is how a workspace outside it is reached without going
|
||||
// through /console.
|
||||
//
|
||||
// A combobox over a listbox, not a Menu: focus stays in the search input
|
||||
// and the arrow keys move the active option (aria-activedescendant), which
|
||||
// the Menu primitive cannot do because it hands focus to its first row.
|
||||
import { tick } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { workspaceStore } from '$lib/stores/workspace.svelte';
|
||||
import { tabsStore } from '$lib/stores/tabs.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { uiStore } from '$lib/stores/ui.svelte';
|
||||
import { pushEscapeHandler, ESCAPE_PRIORITY } from '$lib/stores/escapeStack';
|
||||
import { clickOutside } from '$lib/utils/clickOutside';
|
||||
import { workspaceRestoreTarget } from '$lib/utils/workspace-route';
|
||||
import RecentlyDeletedWorkspaces from '$lib/components/layout/RecentlyDeletedWorkspaces.svelte';
|
||||
import type { Workspace } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onclose: () => void;
|
||||
/** The "+" button: exempt from outside-press, and focus returns to it. */
|
||||
trigger?: HTMLElement;
|
||||
}
|
||||
|
||||
let { open, onclose, trigger }: Props = $props();
|
||||
|
||||
const LISTBOX_ID = 'workspace-discovery-options';
|
||||
|
||||
let query = $state('');
|
||||
let activeIndex = $state(0);
|
||||
let inputEl: HTMLInputElement | undefined = $state(undefined);
|
||||
let panelEl: HTMLElement | undefined = $state(undefined);
|
||||
|
||||
type Option = { kind: 'workspace'; ws: Workspace } | { kind: 'create' };
|
||||
|
||||
// Every visible workspace that is not an open tab, filtered by name or
|
||||
// slug. The list is the workspace store's; the open set is the tabs
|
||||
// store's, so a workspace opened elsewhere drops out as its commit lands.
|
||||
let matches = $derived.by(() => {
|
||||
const openSlugs = new Set(tabsStore.tabs.map((t) => t.slug));
|
||||
const q = query.trim().toLowerCase();
|
||||
return workspaceStore.workspaces.filter(
|
||||
(ws) =>
|
||||
!openSlugs.has(ws.slug) &&
|
||||
(q === '' || ws.name.toLowerCase().includes(q) || ws.slug.toLowerCase().includes(q))
|
||||
);
|
||||
});
|
||||
|
||||
let options = $derived<Option[]>([
|
||||
...matches.map((ws) => ({ kind: 'workspace' as const, ws })),
|
||||
{ kind: 'create' as const },
|
||||
]);
|
||||
|
||||
// Keep the active option in range as the list narrows.
|
||||
$effect(() => {
|
||||
if (activeIndex >= options.length) activeIndex = Math.max(0, options.length - 1);
|
||||
});
|
||||
|
||||
// Reset and focus the search each time the surface opens.
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
query = '';
|
||||
activeIndex = 0;
|
||||
tick().then(() => inputEl?.focus());
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
return pushEscapeHandler(() => {
|
||||
close();
|
||||
return true;
|
||||
}, ESCAPE_PRIORITY.menu);
|
||||
});
|
||||
|
||||
function close() {
|
||||
onclose();
|
||||
trigger?.focus();
|
||||
}
|
||||
|
||||
function optionId(i: number): string {
|
||||
return `${LISTBOX_ID}-${i}`;
|
||||
}
|
||||
|
||||
// Opening from search is an explicit choice, so the tab is KEPT (TASK-3276),
|
||||
// then you land on that workspace's last route. A failed open still
|
||||
// navigates: reaching the workspace is the point, the tab a convenience.
|
||||
async function openWorkspace(ws: Workspace) {
|
||||
onclose();
|
||||
const isSameIdentity = authStore.identityFence();
|
||||
await tabsStore.open(ws.slug).catch(() => {});
|
||||
if (!isSameIdentity()) return;
|
||||
goto(workspaceRestoreTarget(ws));
|
||||
}
|
||||
|
||||
function createWorkspace() {
|
||||
onclose();
|
||||
uiStore.openCreateWorkspace();
|
||||
}
|
||||
|
||||
function activate(option: Option | undefined) {
|
||||
if (!option) return;
|
||||
if (option.kind === 'workspace') void openWorkspace(option.ws);
|
||||
else createWorkspace();
|
||||
}
|
||||
|
||||
function onInputKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
activeIndex = (activeIndex + 1) % options.length;
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
activeIndex = (activeIndex - 1 + options.length) % options.length;
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
activate(options[activeIndex]);
|
||||
} else if (e.key === 'Escape') {
|
||||
// Handled here as well as on the escape stack, so a press in the
|
||||
// input closes this surface and nothing under it (Menu's pattern).
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
function onQueryInput() {
|
||||
activeIndex = 0;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
class="discovery"
|
||||
bind:this={panelEl}
|
||||
use:clickOutside={{
|
||||
enabled: open,
|
||||
onOutside: onclose,
|
||||
extra: () => [trigger],
|
||||
}}
|
||||
>
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
bind:value={query}
|
||||
oninput={onQueryInput}
|
||||
onkeydown={onInputKeydown}
|
||||
class="discovery-search"
|
||||
type="text"
|
||||
role="combobox"
|
||||
aria-label="Find a workspace"
|
||||
aria-expanded="true"
|
||||
aria-controls={LISTBOX_ID}
|
||||
aria-autocomplete="list"
|
||||
aria-activedescendant={options.length ? optionId(activeIndex) : undefined}
|
||||
placeholder="Find a workspace…"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<ul id={LISTBOX_ID} class="discovery-options" role="listbox" aria-label="Workspaces not open">
|
||||
{#each options as option, i (option.kind === 'workspace' ? option.ws.slug : '__create')}
|
||||
<!-- Options are activated through the combobox's keys; a pointer
|
||||
click is the mouse path to the same action. -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<li
|
||||
id={optionId(i)}
|
||||
role="option"
|
||||
aria-selected={i === activeIndex}
|
||||
class="discovery-option"
|
||||
class:active={i === activeIndex}
|
||||
class:create={option.kind === 'create'}
|
||||
data-ws-slug={option.kind === 'workspace' ? option.ws.slug : undefined}
|
||||
onmousedown={(e) => e.preventDefault()}
|
||||
onmousemove={() => (activeIndex = i)}
|
||||
onclick={() => activate(option)}
|
||||
>
|
||||
{#if option.kind === 'workspace'}
|
||||
<span class="discovery-name">{option.ws.name}</span>
|
||||
{#if option.ws.is_guest}<span class="discovery-meta">Shared with you</span>{/if}
|
||||
{:else}
|
||||
<span class="discovery-name">+ New workspace…</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if matches.length === 0}
|
||||
<p class="discovery-empty" aria-live="polite">
|
||||
{query.trim() ? 'No workspace outside your tabs matches.' : 'Every workspace is already open.'}
|
||||
</p>
|
||||
{/if}
|
||||
<!-- Pending invitations go here: PLAN-3002 U4b / TASK-3277. -->
|
||||
<RecentlyDeletedWorkspaces active={open} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.discovery {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 300px;
|
||||
max-height: min(70vh, 480px);
|
||||
overflow-y: auto;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||||
z-index: 30;
|
||||
padding-top: var(--space-2);
|
||||
}
|
||||
.discovery-search {
|
||||
display: block;
|
||||
width: calc(100% - 2 * var(--space-3));
|
||||
margin: 0 var(--space-3) var(--space-2);
|
||||
padding: var(--space-2) var(--space-3);
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.discovery-search:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-blue);
|
||||
}
|
||||
.discovery-options {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.discovery-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
cursor: pointer;
|
||||
font-size: 0.9em;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.discovery-option.active {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.discovery-option.create {
|
||||
color: var(--text-muted);
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.discovery-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.discovery-meta {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8em;
|
||||
}
|
||||
.discovery-empty {
|
||||
margin: 0;
|
||||
padding: var(--space-2) var(--space-4);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,220 @@
|
||||
// Runs in the jsdom vitest project (filename ends `.svelte.test.ts`).
|
||||
//
|
||||
// TASK-3276 (PLAN-3002 U4): the TopBar "+" discovery surface. It lists the
|
||||
// workspaces that are NOT open as tabs, searches them, opens one as a KEPT tab
|
||||
// and lands on its last route, and offers create. Driven through the real
|
||||
// tabs store over a mocked API, so "not open" means what a committed server
|
||||
// answer says.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { render, cleanup, fireEvent } from '@testing-library/svelte';
|
||||
import { tick, flushSync } from 'svelte';
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
goto: vi.fn(async () => {}),
|
||||
workspaces: [] as { id: string; slug: string; name: string; owner_username: string; is_guest?: boolean }[],
|
||||
tabs: {
|
||||
list: vi.fn(),
|
||||
open: vi.fn(),
|
||||
close: vi.fn(),
|
||||
reorder: vi.fn(),
|
||||
update: vi.fn(),
|
||||
},
|
||||
listDeleted: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
vi.mock('$app/navigation', () => ({ goto: mocks.goto }));
|
||||
|
||||
vi.mock('$lib/api/client', () => ({
|
||||
PadApiError: class extends Error {},
|
||||
api: { workspaces: { tabs: mocks.tabs, listDeleted: mocks.listDeleted, restore: vi.fn() } },
|
||||
}));
|
||||
|
||||
vi.mock('$lib/stores/workspace.svelte', () => ({
|
||||
workspaceStore: {
|
||||
get workspaces() {
|
||||
return mocks.workspaces;
|
||||
},
|
||||
current: null,
|
||||
loadAll: vi.fn(async () => {}),
|
||||
},
|
||||
}));
|
||||
|
||||
import WorkspaceDiscovery from './WorkspaceDiscovery.svelte';
|
||||
import { tabsStore } from '$lib/stores/tabs.svelte';
|
||||
import { uiStore } from '$lib/stores/ui.svelte';
|
||||
|
||||
const ws = (slug: string, name: string, extra: { is_guest?: boolean } = {}) => ({
|
||||
id: slug,
|
||||
slug,
|
||||
name,
|
||||
owner_username: 'u',
|
||||
...extra,
|
||||
});
|
||||
|
||||
function answer(slugs: string[], lastRoutes: Record<string, string> = {}) {
|
||||
return {
|
||||
tabs: slugs.map((slug, i) => ({
|
||||
slug,
|
||||
name: slug,
|
||||
owner_username: 'u',
|
||||
is_guest: false,
|
||||
ephemeral: false,
|
||||
position: i,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
...(lastRoutes[slug] ? { last_route: lastRoutes[slug] } : {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
let onclose: ReturnType<typeof vi.fn>;
|
||||
let trigger: HTMLButtonElement;
|
||||
|
||||
async function mountOpen(openSlugs: string[]) {
|
||||
mocks.tabs.list.mockResolvedValueOnce(answer(openSlugs));
|
||||
await tabsStore.load();
|
||||
trigger = document.createElement('button');
|
||||
document.body.appendChild(trigger);
|
||||
onclose = vi.fn();
|
||||
render(WorkspaceDiscovery, { props: { open: true, onclose, trigger } });
|
||||
await tick();
|
||||
await tick();
|
||||
flushSync();
|
||||
}
|
||||
|
||||
const input = () => document.querySelector<HTMLInputElement>('.discovery-search')!;
|
||||
const optionSlugs = () =>
|
||||
Array.from(document.querySelectorAll<HTMLElement>('.discovery-option[data-ws-slug]')).map(
|
||||
(el) => el.dataset.wsSlug
|
||||
);
|
||||
const activeOption = () => document.querySelector<HTMLElement>('.discovery-option[aria-selected="true"]');
|
||||
|
||||
async function key(k: string) {
|
||||
await fireEvent.keyDown(input(), { key: k });
|
||||
flushSync();
|
||||
}
|
||||
|
||||
async function settle() {
|
||||
for (let i = 0; i < 6; i++) await Promise.resolve();
|
||||
await tick();
|
||||
flushSync();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.goto.mockClear();
|
||||
for (const fn of Object.values(mocks.tabs)) fn.mockReset();
|
||||
mocks.workspaces = [ws('alpha', 'Alpha'), ws('beta', 'Beta'), ws('gamma', 'Gamma'), ws('delta', 'Delta', { is_guest: true })];
|
||||
try {
|
||||
localStorage.clear();
|
||||
} catch {}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
uiStore.closeCreateWorkspace();
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
describe('WorkspaceDiscovery: what it lists', () => {
|
||||
it('lists only the workspaces that are not open as tabs, then create', async () => {
|
||||
await mountOpen(['alpha', 'gamma']);
|
||||
expect(optionSlugs()).toEqual(['beta', 'delta']);
|
||||
expect(document.querySelector('.discovery-option.create')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('focuses the search when it opens', async () => {
|
||||
await mountOpen([]);
|
||||
expect(document.activeElement).toBe(input());
|
||||
});
|
||||
|
||||
it('filters by name or slug, case-insensitively', async () => {
|
||||
await mountOpen([]);
|
||||
await fireEvent.input(input(), { target: { value: 'ALP' } });
|
||||
flushSync();
|
||||
expect(optionSlugs()).toEqual(['alpha']);
|
||||
// A slug that shares nothing with its name, so a name-only filter
|
||||
// cannot pass this half.
|
||||
mocks.workspaces = [...mocks.workspaces, ws('ops-2024', 'Operations')];
|
||||
flushSync();
|
||||
await fireEvent.input(input(), { target: { value: 'ops-20' } });
|
||||
flushSync();
|
||||
expect(optionSlugs()).toEqual(['ops-2024']);
|
||||
});
|
||||
|
||||
it('says when nothing outside the tabs matches', async () => {
|
||||
await mountOpen([]);
|
||||
await fireEvent.input(input(), { target: { value: 'zzz' } });
|
||||
flushSync();
|
||||
expect(optionSlugs()).toEqual([]);
|
||||
expect(document.querySelector('.discovery-empty')?.textContent).toMatch(/no workspace/i);
|
||||
});
|
||||
|
||||
it('marks a guest workspace with the shared wording', async () => {
|
||||
await mountOpen(['alpha', 'beta', 'gamma']);
|
||||
expect(document.querySelector('.discovery-option[data-ws-slug="delta"] .discovery-meta')?.textContent).toBe(
|
||||
'Shared with you'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WorkspaceDiscovery: the keyboard path', () => {
|
||||
it('arrows move the active option, wrapping, and the input names it', async () => {
|
||||
await mountOpen(['gamma', 'delta']);
|
||||
// Options: alpha, beta, create.
|
||||
expect(activeOption()?.dataset.wsSlug).toBe('alpha');
|
||||
await key('ArrowDown');
|
||||
expect(activeOption()?.dataset.wsSlug).toBe('beta');
|
||||
await key('ArrowDown');
|
||||
expect(activeOption()?.classList.contains('create')).toBe(true);
|
||||
await key('ArrowDown');
|
||||
expect(activeOption()?.dataset.wsSlug).toBe('alpha');
|
||||
await key('ArrowUp');
|
||||
expect(activeOption()?.classList.contains('create')).toBe(true);
|
||||
expect(input().getAttribute('aria-activedescendant')).toBe(activeOption()?.id);
|
||||
});
|
||||
|
||||
it('Enter opens the active workspace as a KEPT tab, then lands on its last route', async () => {
|
||||
await mountOpen(['alpha']);
|
||||
mocks.tabs.open.mockResolvedValueOnce(answer(['alpha', 'beta'], { beta: '/u/beta/docs' }));
|
||||
await fireEvent.input(input(), { target: { value: 'bet' } });
|
||||
flushSync();
|
||||
await key('Enter');
|
||||
await settle();
|
||||
expect(mocks.tabs.open).toHaveBeenCalledWith('beta', false);
|
||||
expect(onclose).toHaveBeenCalled();
|
||||
expect(mocks.goto).toHaveBeenCalledWith('/u/beta/docs');
|
||||
});
|
||||
|
||||
it('a failed open still navigates', async () => {
|
||||
await mountOpen([]);
|
||||
mocks.tabs.open.mockRejectedValueOnce(new TypeError('network down'));
|
||||
await key('Enter');
|
||||
await settle();
|
||||
expect(mocks.goto).toHaveBeenCalledWith('/u/alpha');
|
||||
});
|
||||
|
||||
it('Enter on create opens the create flow', async () => {
|
||||
await mountOpen(['alpha', 'beta', 'gamma', 'delta']);
|
||||
await key('Enter');
|
||||
expect(uiStore.createWorkspaceOpen).toBe(true);
|
||||
expect(mocks.tabs.open).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Escape closes and returns focus to "+"', async () => {
|
||||
await mountOpen([]);
|
||||
await key('Escape');
|
||||
expect(onclose).toHaveBeenCalled();
|
||||
expect(document.activeElement).toBe(trigger);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WorkspaceDiscovery: the pointer path', () => {
|
||||
it('a click on a workspace opens it the same way', async () => {
|
||||
await mountOpen([]);
|
||||
mocks.tabs.open.mockResolvedValueOnce(answer(['gamma']));
|
||||
document.querySelector<HTMLElement>('.discovery-option[data-ws-slug="gamma"]')!.click();
|
||||
await settle();
|
||||
expect(mocks.tabs.open).toHaveBeenCalledWith('gamma', false);
|
||||
expect(mocks.goto).toHaveBeenCalledWith('/u/gamma');
|
||||
});
|
||||
});
|
||||
@@ -1,13 +1,11 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { api } from '$lib/api/client';
|
||||
import { workspaceStore } from '$lib/stores/workspace.svelte';
|
||||
import { uiStore } from '$lib/stores/ui.svelte';
|
||||
import { toastStore } from '$lib/stores/toast.svelte';
|
||||
import BottomSheet from '$lib/components/common/BottomSheet.svelte';
|
||||
import { viewport } from '$lib/stores/breakpoint.svelte';
|
||||
import { workspaceRestoreTarget } from '$lib/utils/workspace-route';
|
||||
import type { DeletedWorkspace } from '$lib/types';
|
||||
import RecentlyDeletedWorkspaces from '$lib/components/layout/RecentlyDeletedWorkspaces.svelte';
|
||||
|
||||
interface Props {
|
||||
/**
|
||||
@@ -70,71 +68,6 @@
|
||||
uiStore.openCreateWorkspace();
|
||||
}
|
||||
|
||||
// ── Recently deleted ─────────────────────────────────────────────────
|
||||
// Soft-deleted workspaces still inside the restore window (TASK-1974).
|
||||
// The section only renders when the fetch returns at least one entry, so
|
||||
// a quiet failure (or an empty list) leaves the switcher untouched.
|
||||
let deleted = $state<DeletedWorkspace[]>([]);
|
||||
// Collapsed by default; the header toggles it. Kept separate from `open`
|
||||
// so the choice persists while the dropdown stays open.
|
||||
let deletedExpanded = $state(false);
|
||||
// Slug currently being restored — drives the per-row disabled/pending
|
||||
// state so a double-click can't fire two restores.
|
||||
let restoringSlug = $state<string | null>(null);
|
||||
// Monotonic token guarding async listDeleted() responses against races —
|
||||
// mirrors workspaceStore's membershipSeq. Each loadDeleted() call claims a
|
||||
// token; a response only writes if its token is still current, so an older
|
||||
// open-triggered fetch can't clobber the fresher post-restore refresh (and
|
||||
// re-surface a just-restored workspace).
|
||||
let deletedSeq = 0;
|
||||
|
||||
async function loadDeleted() {
|
||||
const seq = ++deletedSeq;
|
||||
try {
|
||||
const list = await api.workspaces.listDeleted();
|
||||
if (seq === deletedSeq) deleted = list;
|
||||
} catch {
|
||||
// Swallow — the restore affordance is a bonus, never a blocker for
|
||||
// switching workspaces. Clear the list so a stale fetch doesn't
|
||||
// linger and the section stays hidden.
|
||||
if (seq === deletedSeq) deleted = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh the deleted list each time the switcher opens.
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
loadDeleted();
|
||||
}
|
||||
});
|
||||
|
||||
async function restore(ws: DeletedWorkspace) {
|
||||
if (restoringSlug) return;
|
||||
restoringSlug = ws.slug;
|
||||
try {
|
||||
try {
|
||||
await api.workspaces.restore(ws.slug);
|
||||
} catch {
|
||||
// Only the restore call itself failing is a restore failure.
|
||||
toastStore.show(`Couldn't restore "${ws.name}"`, 'error');
|
||||
return;
|
||||
}
|
||||
// Restore succeeded — confirm before refreshing so a failing
|
||||
// reload can't masquerade as a restore failure.
|
||||
toastStore.show(`Restored "${ws.name}"`, 'success');
|
||||
// Refresh both lists so the restored workspace drops out of the
|
||||
// deleted section and reappears in the active list. loadDeleted()
|
||||
// swallows its own errors; guard loadAll() so a reload failure
|
||||
// stays silent — the restore already went through.
|
||||
try {
|
||||
await Promise.all([loadDeleted(), workspaceStore.loadAll()]);
|
||||
} catch {
|
||||
// Reload failure is non-fatal.
|
||||
}
|
||||
} finally {
|
||||
restoringSlug = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{#snippet workspaceList()}
|
||||
@@ -152,46 +85,6 @@
|
||||
</button>
|
||||
{/snippet}
|
||||
|
||||
{#snippet deletedSection()}
|
||||
<!--
|
||||
Recently-deleted workspaces still inside the restore window. Hidden
|
||||
entirely when the list is empty (or the fetch failed) — no header, no
|
||||
row — so it never adds noise to a switcher with nothing to restore.
|
||||
-->
|
||||
{#if deleted.length > 0}
|
||||
<div class="deleted-section">
|
||||
<button
|
||||
class="item deleted-header"
|
||||
onclick={() => (deletedExpanded = !deletedExpanded)}
|
||||
aria-expanded={deletedExpanded}
|
||||
>
|
||||
<span class="deleted-caret" aria-hidden="true">{deletedExpanded ? '▾' : '▸'}</span>
|
||||
<span class="deleted-title">Recently deleted</span>
|
||||
<span class="deleted-count">{deleted.length}</span>
|
||||
</button>
|
||||
{#if deletedExpanded}
|
||||
<div class="deleted-list">
|
||||
{#each deleted as ws (ws.slug)}
|
||||
<div class="deleted-item">
|
||||
<span class="deleted-name" title={ws.name}>{ws.name}</span>
|
||||
<span class="deleted-days">
|
||||
{ws.days_left} {ws.days_left === 1 ? 'day' : 'days'} left
|
||||
</span>
|
||||
<button
|
||||
class="restore-btn"
|
||||
onclick={() => restore(ws)}
|
||||
disabled={restoringSlug === ws.slug}
|
||||
>
|
||||
{restoringSlug === ws.slug ? 'Restoring…' : 'Restore'}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<div class="switcher">
|
||||
<button
|
||||
class="current"
|
||||
@@ -217,7 +110,7 @@
|
||||
>
|
||||
<div class="sheet-body">
|
||||
{@render workspaceList()}
|
||||
{@render deletedSection()}
|
||||
<RecentlyDeletedWorkspaces active={open} roomy />
|
||||
</div>
|
||||
</BottomSheet>
|
||||
{:else if open}
|
||||
@@ -226,7 +119,7 @@
|
||||
<div class="backdrop" onclick={() => open = false}></div>
|
||||
<div class="dropdown">
|
||||
{@render workspaceList()}
|
||||
{@render deletedSection()}
|
||||
<RecentlyDeletedWorkspaces active={open} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -295,62 +188,4 @@
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* ── Recently deleted ─────────────────────────────────────────────── */
|
||||
.deleted-section { border-top: 1px solid var(--border); }
|
||||
.deleted-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.deleted-caret { font-size: 0.9em; flex-shrink: 0; }
|
||||
.deleted-title { flex: 1; min-width: 0; }
|
||||
.deleted-count {
|
||||
flex-shrink: 0;
|
||||
padding: 0 var(--space-2);
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.deleted-list { display: flex; flex-direction: column; }
|
||||
.deleted-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2) var(--space-4);
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.deleted-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.deleted-days {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.restore-btn {
|
||||
flex-shrink: 0;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--accent-blue);
|
||||
cursor: pointer;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
.restore-btn:hover:not(:disabled) { background: var(--bg-hover); }
|
||||
.restore-btn:disabled { opacity: 0.6; cursor: default; }
|
||||
|
||||
/* Inside the mobile sheet, give the deleted rows the same roomier
|
||||
padding as the workspace rows above. */
|
||||
.sheet-body .deleted-item { padding: var(--space-2) var(--space-3); }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user