summaryrefslogtreecommitdiffhomepage
path: root/src/features/workspaces/logic
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/workspaces/logic')
-rw-r--r--src/features/workspaces/logic/route.test.ts77
-rw-r--r--src/features/workspaces/logic/route.ts63
-rw-r--r--src/features/workspaces/logic/view-model.test.ts179
-rw-r--r--src/features/workspaces/logic/view-model.ts71
4 files changed, 390 insertions, 0 deletions
diff --git a/src/features/workspaces/logic/route.test.ts b/src/features/workspaces/logic/route.test.ts
new file mode 100644
index 0000000..96e0ff3
--- /dev/null
+++ b/src/features/workspaces/logic/route.test.ts
@@ -0,0 +1,77 @@
+import { describe, expect, it } from "vitest";
+import {
+ DEFAULT_WORKSPACE_ID,
+ isValidSlug,
+ parsePath,
+ WORKSPACE_SLUG_RE,
+ workspacePath,
+} from "./route";
+
+describe("parsePath", () => {
+ it("treats the root path as home", () => {
+ expect(parsePath("/")).toEqual({ kind: "home" });
+ expect(parsePath("")).toEqual({ kind: "home" });
+ });
+
+ it("trims surrounding slashes", () => {
+ expect(parsePath("//")).toEqual({ kind: "home" });
+ expect(parsePath("/my-ws/")).toEqual({ kind: "workspace", id: "my-ws" });
+ });
+
+ it("parses a single segment as a workspace id", () => {
+ expect(parsePath("/default")).toEqual({ kind: "workspace", id: "default" });
+ expect(parsePath("/my-workspace")).toEqual({ kind: "workspace", id: "my-workspace" });
+ expect(parsePath("/ws1")).toEqual({ kind: "workspace", id: "ws1" });
+ });
+
+ it("takes only the first segment of a deeper path", () => {
+ expect(parsePath("/foo/bar")).toEqual({ kind: "workspace", id: "foo" });
+ expect(parsePath("/foo/bar/baz")).toEqual({ kind: "workspace", id: "foo" });
+ });
+
+ it("URL-decodes the segment", () => {
+ expect(parsePath("/my%20ws")).toEqual({ kind: "workspace", id: "my ws" });
+ });
+
+ it("does not validate the slug — an invalid id is still a workspace route", () => {
+ expect(parsePath("/UPPER")).toEqual({ kind: "workspace", id: "UPPER" });
+ expect(parsePath("/has space")).toEqual({ kind: "workspace", id: "has space" });
+ });
+});
+
+describe("isValidSlug", () => {
+ it("accepts lowercase alphanumeric + internal hyphens", () => {
+ expect(isValidSlug("default")).toBe(true);
+ expect(isValidSlug("my-workspace")).toBe(true);
+ expect(isValidSlug("a")).toBe(true);
+ expect(isValidSlug("ws-1")).toBe(true);
+ });
+
+ it("accepts up to 40 chars", () => {
+ expect(isValidSlug("a".repeat(40))).toBe(true);
+ });
+
+ it("rejects empty and too-long", () => {
+ expect(isValidSlug("")).toBe(false);
+ expect(isValidSlug("a".repeat(41))).toBe(false);
+ });
+
+ it("rejects uppercase, spaces, and leading/trailing hyphens", () => {
+ expect(isValidSlug("MyWS")).toBe(false);
+ expect(isValidSlug("has space")).toBe(false);
+ expect(isValidSlug("-leading")).toBe(false);
+ expect(isValidSlug("trailing-")).toBe(false);
+ expect(isValidSlug("double--hyphen")).toBe(true); // internal doubles are allowed by the regex
+ });
+
+ it("WORKSPACE_SLUG_RE matches the default id", () => {
+ expect(WORKSPACE_SLUG_RE.test(DEFAULT_WORKSPACE_ID)).toBe(true);
+ });
+});
+
+describe("workspacePath", () => {
+ it("builds the URL path for a workspace id", () => {
+ expect(workspacePath("default")).toBe("/default");
+ expect(workspacePath("my-ws")).toBe("/my-ws");
+ });
+});
diff --git a/src/features/workspaces/logic/route.ts b/src/features/workspaces/logic/route.ts
new file mode 100644
index 0000000..015c6d6
--- /dev/null
+++ b/src/features/workspaces/logic/route.ts
@@ -0,0 +1,63 @@
+/**
+ * Pure routing logic for the workspaces feature — zero DOM, zero effects, zero Svelte.
+ *
+ * The app is URL-driven: the root path `/` is the workspaces HOME (lists all
+ * workspaces); a single path segment `/<id>` opens the workspace with that id
+ * (the slug). This module holds the pure mapping from a pathname to a `Route`,
+ * plus the slug-validation rules mirrored from the backend's `PUT /workspaces/:id`.
+ */
+
+/**
+ * The workspace slug regex (mirrors the backend's `PUT /workspaces/:id`
+ * validation): 1–40 chars, lowercase alphanumeric with internal hyphens only
+ * (no leading/trailing hyphen). `"default"` matches and is a valid (but
+ * non-deletable) id.
+ */
+export const WORKSPACE_SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,38}[a-z0-9])?$/;
+
+/** A route derived from the URL path. */
+export type Route = { readonly kind: "home" } | { readonly kind: "workspace"; readonly id: string };
+
+/** The reserved id of the always-present fallback workspace. */
+export const DEFAULT_WORKSPACE_ID = "default";
+
+/**
+ * Parse a pathname into a `Route`. `/` (or empty) → home; a leading segment →
+ * the workspace with that id (URL-decoded, surrounding slashes trimmed). Deeper
+ * paths take their FIRST segment (nested routes are not used in v1). Pure:
+ * pathname in, route out. Does NOT validate the slug — an invalid id still
+ * produces a `workspace` route; the backend's ensure call rejects it (the FE
+ * surfaces the error).
+ */
+export function parsePath(pathname: string): Route {
+ const trimmed = pathname.replace(/^\/+|\/+$/g, "");
+ if (trimmed === "") return { kind: "home" };
+ const first = trimmed.split("/")[0] ?? "";
+ const id = safeDecode(first);
+ if (id === "") return { kind: "home" };
+ return { kind: "workspace", id };
+}
+
+/**
+ * Whether a slug is valid for a NEW workspace (the form the backend accepts).
+ * Used by the home view's "new workspace" input before navigating.
+ */
+export function isValidSlug(slug: string): boolean {
+ return WORKSPACE_SLUG_RE.test(slug);
+}
+
+/**
+ * Build the URL path for a workspace id. Used when navigating to / linking a
+ * workspace. Pure: id in, path string out.
+ */
+export function workspacePath(id: string): string {
+ return `/${id}`;
+}
+
+function safeDecode(segment: string): string {
+ try {
+ return decodeURIComponent(segment);
+ } catch {
+ return segment;
+ }
+}
diff --git a/src/features/workspaces/logic/view-model.test.ts b/src/features/workspaces/logic/view-model.test.ts
new file mode 100644
index 0000000..44d2f31
--- /dev/null
+++ b/src/features/workspaces/logic/view-model.test.ts
@@ -0,0 +1,179 @@
+import type { WorkspaceEntry } from "@dispatch/wire";
+import { describe, expect, it } from "vitest";
+import { applyStarred, pageTitle, relativeTime, sortWorkspaces } from "./view-model";
+
+describe("relativeTime", () => {
+ const now = 1_000_000_000_000; // 2001-09-09
+
+ it("is 'now' within a minute", () => {
+ expect(relativeTime(now, now)).toBe("now");
+ expect(relativeTime(now - 59_000, now)).toBe("now");
+ });
+
+ it("is minutes under an hour", () => {
+ expect(relativeTime(now - 5 * 60_000, now)).toBe("5m");
+ expect(relativeTime(now - 59 * 60_000, now)).toBe("59m");
+ });
+
+ it("is hours under a day", () => {
+ expect(relativeTime(now - 2 * 60 * 60_000, now)).toBe("2h");
+ });
+
+ it("is days under a week", () => {
+ expect(relativeTime(now - 3 * 24 * 60 * 60_000, now)).toBe("3d");
+ });
+
+ it("is a short date beyond a week", () => {
+ // 7+ days ago: just check it is a MM/DD string.
+ const s = relativeTime(now - 10 * 24 * 60 * 60_000, now);
+ expect(s).toMatch(/^\d{2}\/\d{2}$/);
+ });
+});
+
+describe("pageTitle", () => {
+ // Minimal valid WorkspaceEntry (the irrelevant metadata is zeroed).
+ const ws = (id: string, title: string): WorkspaceEntry => ({
+ id,
+ title,
+ defaultCwd: null,
+ defaultComputerId: null,
+ starred: false,
+ createdAt: 0,
+ lastActivityAt: 0,
+ conversationCount: 0,
+ });
+
+ it("is 'Dispatch' for the home route", () => {
+ expect(pageTitle({ kind: "home" }, [])).toBe("Dispatch");
+ expect(pageTitle({ kind: "home" }, [ws("default", "Default")])).toBe("Dispatch");
+ });
+
+ it("is 'Dispatch: {title}' for a workspace with a display title", () => {
+ const list = [ws("default", "Default"), ws("my-ws", "My Workspace")];
+ expect(pageTitle({ kind: "workspace", id: "my-ws" }, list)).toBe("Dispatch: My Workspace");
+ });
+
+ it("falls back to the slug (id) until the list has loaded the workspace", () => {
+ expect(pageTitle({ kind: "workspace", id: "pending" }, [])).toBe("Dispatch: pending");
+ });
+
+ it("uses the id as the title when it was never customized (defaults to id)", () => {
+ const list = [ws("default", "default")];
+ expect(pageTitle({ kind: "workspace", id: "default" }, list)).toBe("Dispatch: default");
+ });
+
+ it("matches by id, not title", () => {
+ const list = [ws("a", "shared-title"), ws("b", "shared-title")];
+ expect(pageTitle({ kind: "workspace", id: "b" }, list)).toBe("Dispatch: shared-title");
+ });
+});
+
+describe("sortWorkspaces", () => {
+ const entry = (id: string, starred: boolean, lastActivityAt: number): WorkspaceEntry => ({
+ id,
+ title: id,
+ defaultCwd: null,
+ defaultComputerId: null,
+ starred,
+ createdAt: 0,
+ lastActivityAt,
+ conversationCount: 0,
+ });
+
+ it("puts starred workspaces before unstarred", () => {
+ const list = [entry("plain", false, 9_000), entry("star", true, 1_000)];
+ expect(sortWorkspaces(list).map((w) => w.id)).toEqual(["star", "plain"]);
+ });
+
+ it("within the starred group, sorts by lastActivityAt desc", () => {
+ const list = [
+ entry("old-star", true, 1_000),
+ entry("new-star", true, 5_000),
+ entry("plain", false, 9_000),
+ ];
+ expect(sortWorkspaces(list).map((w) => w.id)).toEqual(["new-star", "old-star", "plain"]);
+ });
+
+ it("within the unstarred group, sorts by lastActivityAt desc", () => {
+ const list = [
+ entry("star", true, 1_000),
+ entry("old-plain", false, 1_000),
+ entry("new-plain", false, 5_000),
+ ];
+ expect(sortWorkspaces(list).map((w) => w.id)).toEqual(["star", "new-plain", "old-plain"]);
+ });
+
+ it("returns a new array (does not mutate the input)", () => {
+ const list = [entry("plain", false, 9_000), entry("star", true, 1_000)];
+ const sorted = sortWorkspaces(list);
+ expect(sorted).not.toBe(list);
+ // Input order is preserved (not mutated).
+ expect(list.map((w) => w.id)).toEqual(["plain", "star"]);
+ expect(sorted.map((w) => w.id)).toEqual(["star", "plain"]);
+ });
+
+ it("handles an empty list", () => {
+ expect(sortWorkspaces([])).toEqual([]);
+ });
+
+ it("is stable for equal lastActivityAt within a group", () => {
+ const list = [
+ entry("first", false, 5_000),
+ entry("second", false, 5_000),
+ entry("third", false, 5_000),
+ ];
+ expect(sortWorkspaces(list).map((w) => w.id)).toEqual(["first", "second", "third"]);
+ });
+});
+
+describe("applyStarred", () => {
+ const entry = (id: string, starred: boolean): WorkspaceEntry => ({
+ id,
+ title: id,
+ defaultCwd: null,
+ defaultComputerId: null,
+ starred,
+ createdAt: 0,
+ lastActivityAt: 0,
+ conversationCount: 0,
+ });
+
+ it("sets the named workspace's starred flag", () => {
+ const list = [entry("a", false), entry("b", false)];
+ const next = applyStarred(list, "b", true);
+ expect(next.map((w) => [w.id, w.starred])).toEqual([
+ ["a", false],
+ ["b", true],
+ ]);
+ });
+
+ it("returns a new array (does not mutate the input)", () => {
+ const list = [entry("a", false)];
+ const next = applyStarred(list, "a", true);
+ expect(next).not.toBe(list);
+ expect(list[0]?.starred).toBe(false);
+ expect(next[0]?.starred).toBe(true);
+ });
+
+ it("leaves other entries referentially unchanged (only the target is replaced)", () => {
+ const a = entry("a", false);
+ const b = entry("b", false);
+ const next = applyStarred([a, b], "b", true);
+ expect(next[0]).toBe(a);
+ expect(next[1]).not.toBe(b);
+ });
+
+ it("leaves the list unchanged when the id is absent (not yet loaded)", () => {
+ const list = [entry("a", false)];
+ const next = applyStarred(list, "missing", true);
+ expect(next.map((w) => [w.id, w.starred])).toEqual([["a", false]]);
+ });
+
+ it("can revert by re-applying the previous value", () => {
+ const list = [entry("a", false)];
+ const optimistic = applyStarred(list, "a", true);
+ expect(optimistic[0]?.starred).toBe(true);
+ const reverted = applyStarred(optimistic, "a", false);
+ expect(reverted[0]?.starred).toBe(false);
+ });
+});
diff --git a/src/features/workspaces/logic/view-model.ts b/src/features/workspaces/logic/view-model.ts
new file mode 100644
index 0000000..b994b7c
--- /dev/null
+++ b/src/features/workspaces/logic/view-model.ts
@@ -0,0 +1,71 @@
+/**
+ * Pure view-model helpers for the workspaces feature — zero DOM, zero effects.
+ */
+
+import type { WorkspaceEntry } from "@dispatch/wire";
+import type { Route } from "./route";
+
+/**
+ * The browser tab / page (`document.title`) text for a route. The home route
+ * (`/`) is "Dispatch"; a workspace route (`/<id>`) is "Dispatch: {title}",
+ * using the workspace's display title and falling back to the URL slug (`id`)
+ * until the workspace list has loaded it — the backend defaults a workspace's
+ * title to its id, so the slug is the correct transient value. Pure: route +
+ * workspaces in, string out.
+ */
+export function pageTitle(route: Route, workspaces: readonly WorkspaceEntry[]): string {
+ if (route.kind === "home") return "Dispatch";
+ const ws = workspaces.find((w) => w.id === route.id);
+ return `Dispatch: ${ws?.title ?? route.id}`;
+}
+
+/**
+ * Sort workspaces for display: starred first, then most-recently-active. Pure:
+ * the list in, a NEW sorted array out (the input is not mutated). Starred
+ * workspaces jump to the top (the FE-side echo of their concurrency-priority);
+ * within each group (starred / not) `lastActivityAt` desc breaks ties, matching
+ * the backend's list ordering. Stable for equal `lastActivityAt`.
+ */
+export function sortWorkspaces<T extends WorkspaceEntry>(workspaces: readonly T[]): T[] {
+ return [...workspaces].sort((a, b) => {
+ if (a.starred !== b.starred) return a.starred ? -1 : 1;
+ return b.lastActivityAt - a.lastActivityAt;
+ });
+}
+
+/**
+ * Return a NEW list with the one workspace's `starred` flag set (immutably —
+ * the entry is replaced, the rest keep their identity). Pure: the optimistic
+ * star/unstar transformation shared by the apply + the error revert. A missing
+ * `id` (not yet in the list — e.g. starring a workspace the home view hasn't
+ * loaded) leaves the list unchanged; the backend's create-on-miss still applies
+ * server-side and a subsequent refresh reconciles.
+ */
+export function applyStarred<T extends WorkspaceEntry>(
+ workspaces: readonly T[],
+ id: string,
+ starred: boolean,
+): T[] {
+ return workspaces.map((w) => (w.id === id ? { ...w, starred } : w));
+}
+
+/**
+ * Format an epoch-ms timestamp as a short relative string ("now", "3m", "2h",
+ * "5d", or a date). Pure: `now` + `then` in, string out. Future timestamps
+ * (a workspace just created) read as "now".
+ */
+export function relativeTime(then: number, now: number): string {
+ const diff = now - then;
+ if (diff < 60_000) return "now";
+ const mins = Math.floor(diff / 60_000);
+ if (mins < 60) return `${mins}m`;
+ const hours = Math.floor(mins / 60);
+ if (hours < 24) return `${hours}h`;
+ const days = Math.floor(hours / 24);
+ if (days < 7) return `${days}d`;
+ // Beyond a week: a short date (MM/DD). Uses UTC parts for determinism in tests.
+ const d = new Date(then);
+ const month = String(d.getUTCMonth() + 1).padStart(2, "0");
+ const day = String(d.getUTCDate()).padStart(2, "0");
+ return `${month}/${day}`;
+}