diff options
Diffstat (limited to 'src/features/workspace')
| -rw-r--r-- | src/features/workspace/index.ts | 14 | ||||
| -rw-r--r-- | src/features/workspace/logic/view-model.test.ts | 101 | ||||
| -rw-r--r-- | src/features/workspace/logic/view-model.ts | 130 | ||||
| -rw-r--r-- | src/features/workspace/ui/CwdField.svelte | 96 | ||||
| -rw-r--r-- | src/features/workspace/ui/LspStatusView.svelte | 127 |
5 files changed, 0 insertions, 468 deletions
diff --git a/src/features/workspace/index.ts b/src/features/workspace/index.ts deleted file mode 100644 index 9acf994..0000000 --- a/src/features/workspace/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -export type { - CwdSaveResult, - LoadLspStatus, - LspStatusResult, - SaveCwd, -} from "./logic/view-model"; -export { default as CwdField } from "./ui/CwdField.svelte"; -export { default as LspStatusView } from "./ui/LspStatusView.svelte"; - -/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ -export const manifest = { - name: "workspace", - description: "Per-conversation working directory + language-server status", -} as const; diff --git a/src/features/workspace/logic/view-model.test.ts b/src/features/workspace/logic/view-model.test.ts deleted file mode 100644 index a06edeb..0000000 --- a/src/features/workspace/logic/view-model.test.ts +++ /dev/null @@ -1,101 +0,0 @@ -import type { LspServerInfo } from "@dispatch/transport-contract"; -import { describe, expect, it } from "vitest"; -import { - cwdChanged, - isSubmittableCwd, - normalizeCwd, - summarizeServers, - viewLspServer, - viewLspServers, -} from "./view-model"; - -const server = (over: Partial<LspServerInfo> = {}): LspServerInfo => ({ - id: "typescript", - name: "TypeScript", - root: "/home/me/project", - extensions: [".ts", ".tsx"], - state: "connected", - ...over, -}); - -describe("cwd helpers", () => { - it("normalizeCwd trims surrounding whitespace", () => { - expect(normalizeCwd(" /a/b ")).toBe("/a/b"); - expect(normalizeCwd("\t/x\n")).toBe("/x"); - }); - - it("isSubmittableCwd is false for empty / whitespace-only", () => { - expect(isSubmittableCwd("")).toBe(false); - expect(isSubmittableCwd(" ")).toBe(false); - expect(isSubmittableCwd("/a")).toBe(true); - }); - - it("cwdChanged: true only when a non-empty trimmed value differs from current", () => { - expect(cwdChanged("/a/b", null)).toBe(true); - expect(cwdChanged("/a/b", "/a/b")).toBe(false); - expect(cwdChanged(" /a/b ", "/a/b")).toBe(false); // trim-equal → no change - expect(cwdChanged("/a/c", "/a/b")).toBe(true); - expect(cwdChanged("", "/a/b")).toBe(false); // empty is not a change (can't clear) - expect(cwdChanged(" ", null)).toBe(false); - }); -}); - -describe("viewLspServer", () => { - it("connected → success badge, not busy, no error", () => { - const v = viewLspServer(server({ state: "connected" })); - expect(v.badge).toBe("success"); - expect(v.statusLabel).toBe("Connected"); - expect(v.busy).toBe(false); - expect(v.error).toBeNull(); - expect(v.extensionsLabel).toBe(".ts .tsx"); - }); - - it("starting / not-started → busy (spinner) with warning / neutral badge", () => { - const starting = viewLspServer(server({ state: "starting" })); - expect(starting.badge).toBe("warning"); - expect(starting.busy).toBe(true); - - const notStarted = viewLspServer(server({ state: "not-started" })); - expect(notStarted.badge).toBe("neutral"); - expect(notStarted.busy).toBe(true); - }); - - it("error → error badge + surfaces the reason (with a fallback)", () => { - const withReason = viewLspServer(server({ state: "error", error: "ENOENT" })); - expect(withReason.badge).toBe("error"); - expect(withReason.busy).toBe(false); - expect(withReason.error).toBe("ENOENT"); - - const noReason = viewLspServer(server({ state: "error" })); - expect(noReason.error).toBe("Failed to start"); - }); - - it("viewLspServers maps a list preserving order", () => { - const views = viewLspServers([server({ id: "a" }), server({ id: "b" })]); - expect(views.map((v) => v.id)).toEqual(["a", "b"]); - }); -}); - -describe("summarizeServers", () => { - it("empty list", () => { - expect(summarizeServers([])).toBe("No language servers"); - }); - - it("counts connected / starting / errors", () => { - expect(summarizeServers([server({ state: "connected" })])).toBe("1 connected"); - expect( - summarizeServers([ - server({ id: "a", state: "connected" }), - server({ id: "b", state: "error" }), - ]), - ).toBe("1 connected, 1 error"); - expect( - summarizeServers([ - server({ id: "a", state: "connected" }), - server({ id: "b", state: "starting" }), - server({ id: "c", state: "error" }), - server({ id: "d", state: "error" }), - ]), - ).toBe("1 connected, 1 starting, 2 errors"); - }); -}); diff --git a/src/features/workspace/logic/view-model.ts b/src/features/workspace/logic/view-model.ts deleted file mode 100644 index bc9b30b..0000000 --- a/src/features/workspace/logic/view-model.ts +++ /dev/null @@ -1,130 +0,0 @@ -import type { LspServerInfo, LspServerState } from "@dispatch/transport-contract"; - -/** - * Pure core for the workspace feature — zero DOM, zero effects, zero Svelte. - * - * The workspace feature exposes a conversation's per-tab working directory (cwd) - * and the live status of the language servers configured for that cwd. This - * module holds the pure logic: cwd normalization/validation, the mapping of a - * backend `LspServerState` to a display badge, and a one-line server summary. - * The effects (the HTTP get/set cwd + get LSP status) are INJECTED via the ports - * below; the composition root implements them. - */ - -// ── Injected ports (consumer-defines-port; the composition root adapts the -// store's HTTP calls to these shapes). ────────────────────────────────────── - -/** Outcome of `PUT /conversations/:id/cwd`; `null` when no real conversation is focused. */ -export type CwdSaveResult = - | { readonly ok: true; readonly cwd: string | null } - | { readonly ok: false; readonly error: string }; - -export type SaveCwd = (cwd: string) => Promise<CwdSaveResult | null>; - -/** Outcome of `GET /conversations/:id/lsp`; `null` when no real conversation is focused. */ -export type LspStatusResult = - | { readonly ok: true; readonly cwd: string | null; readonly servers: readonly LspServerInfo[] } - | { readonly ok: false; readonly error: string }; - -export type LoadLspStatus = () => Promise<LspStatusResult | null>; - -// ── cwd helpers ─────────────────────────────────────────────────────────────── - -/** Trim surrounding whitespace; the backend rejects an empty cwd. */ -export function normalizeCwd(raw: string): string { - return raw.trim(); -} - -/** Whether a typed cwd is submittable (non-empty after trim). */ -export function isSubmittableCwd(raw: string): boolean { - return normalizeCwd(raw).length > 0; -} - -/** - * Whether saving `typed` would change the persisted `current` cwd. A no-op save - * (unchanged, or empty) should be disabled. - */ -export function cwdChanged(typed: string, current: string | null): boolean { - const next = normalizeCwd(typed); - if (next.length === 0) return false; - return next !== (current ?? ""); -} - -// ── LSP server status → display view ────────────────────────────────────────── - -export type Badge = "success" | "warning" | "error" | "neutral"; - -export interface LspServerView { - readonly id: string; - readonly name: string; - readonly root: string; - /** Space-joined extension list, e.g. ".ts .tsx". */ - readonly extensionsLabel: string; - readonly state: LspServerState; - readonly statusLabel: string; - readonly badge: Badge; - /** True while the state is transient (show a spinner). */ - readonly busy: boolean; - /** The error reason when `state === "error"`, else null. */ - readonly error: string | null; -} - -/** Map a server's state to a display label + badge severity + busy flag. */ -export function viewLspServer(server: LspServerInfo): LspServerView { - let statusLabel: string; - let badge: Badge; - let busy = false; - switch (server.state) { - case "connected": - statusLabel = "Connected"; - badge = "success"; - break; - case "starting": - statusLabel = "Starting…"; - badge = "warning"; - busy = true; - break; - case "not-started": - statusLabel = "Not started"; - badge = "neutral"; - busy = true; - break; - case "error": - statusLabel = "Error"; - badge = "error"; - break; - } - return { - id: server.id, - name: server.name, - root: server.root, - extensionsLabel: server.extensions.join(" "), - state: server.state, - statusLabel, - badge, - busy, - error: server.state === "error" ? (server.error ?? "Failed to start") : null, - }; -} - -export function viewLspServers(servers: readonly LspServerInfo[]): readonly LspServerView[] { - return servers.map(viewLspServer); -} - -/** A short one-line summary, e.g. "2 connected" / "1 connected, 1 error". */ -export function summarizeServers(servers: readonly LspServerInfo[]): string { - if (servers.length === 0) return "No language servers"; - let connected = 0; - let errored = 0; - let pending = 0; - for (const s of servers) { - if (s.state === "connected") connected++; - else if (s.state === "error") errored++; - else pending++; - } - const parts: string[] = []; - if (connected > 0) parts.push(`${connected} connected`); - if (pending > 0) parts.push(`${pending} starting`); - if (errored > 0) parts.push(`${errored} error${errored === 1 ? "" : "s"}`); - return parts.join(", "); -} diff --git a/src/features/workspace/ui/CwdField.svelte b/src/features/workspace/ui/CwdField.svelte deleted file mode 100644 index bd8b870..0000000 --- a/src/features/workspace/ui/CwdField.svelte +++ /dev/null @@ -1,96 +0,0 @@ -<script lang="ts"> - import { untrack } from "svelte"; - import { cwdChanged, normalizeCwd, type SaveCwd } from "../logic/view-model"; - - let { - cwd, - canEdit, - save, - }: { - /** The active conversation's persisted cwd, or null when unset. */ - cwd: string | null; - /** Whether a real conversation is focused (a draft can't persist a cwd yet). */ - canEdit: boolean; - save: SaveCwd; - } = $props(); - - // Start empty; the $effect below seeds from the (async-loaded) cwd prop. (Reading - // the prop directly into initial $state would only capture its first value.) - let value = $state(""); - let lastSeed = $state(""); - let saving = $state(false); - let error = $state<string | null>(null); - let justSaved = $state(false); - - // Seed the input from the persisted cwd (it loads async). Only reseed while the - // field is untouched, so an in-flight load can't clobber what the user typed. - // Re-mounted per conversation, so there is no cross-tab bleed. - $effect(() => { - const incoming = cwd ?? ""; - untrack(() => { - if (value === lastSeed) value = incoming; - lastSeed = incoming; - }); - }); - - const dirty = $derived(cwdChanged(value, cwd)); - - async function handleSave() { - if (saving || !canEdit || !dirty) return; - saving = true; - error = null; - justSaved = false; - const result = await save(normalizeCwd(value)); - saving = false; - if (result === null) return; - if (result.ok) { - justSaved = true; - } else { - error = result.error; - } - } - - function onInput() { - justSaved = false; - error = null; - } -</script> - -<div class="flex flex-col gap-1"> - <span class="text-xs font-semibold uppercase opacity-60">Working directory</span> - <div class="flex items-center gap-2"> - <input - type="text" - class="input input-bordered input-sm w-full font-mono text-xs" - placeholder={canEdit ? "/abs/path/to/project" : "Open a conversation first"} - bind:value - disabled={!canEdit || saving} - oninput={onInput} - onkeydown={(e) => { - if (e.key === "Enter") handleSave(); - }} - aria-label="Working directory" - /> - <button - type="button" - class="btn btn-primary btn-sm" - disabled={!canEdit || saving || !dirty} - onclick={handleSave} - > - {#if saving} - <span class="loading loading-spinner loading-xs"></span> - {:else} - Set - {/if} - </button> - </div> - {#if !canEdit} - <p class="text-xs opacity-60">Start or open a conversation to set its working directory.</p> - {:else if error} - <p class="text-xs text-error">{error}</p> - {:else if justSaved && !dirty} - <p class="text-xs text-success">Saved.</p> - {:else} - <p class="text-xs opacity-50">Defaults each turn's cwd; drives the language servers below.</p> - {/if} -</div> diff --git a/src/features/workspace/ui/LspStatusView.svelte b/src/features/workspace/ui/LspStatusView.svelte deleted file mode 100644 index 77603a1..0000000 --- a/src/features/workspace/ui/LspStatusView.svelte +++ /dev/null @@ -1,127 +0,0 @@ -<script lang="ts"> - import { untrack } from "svelte"; - import { - type Badge, - type LoadLspStatus, - type LspServerView, - summarizeServers, - viewLspServers, - } from "../logic/view-model"; - - let { - cwd, - canView, - load, - }: { - /** The active conversation's cwd — the trigger to (re)load when it changes. */ - cwd: string | null; - /** Whether a real conversation is focused. */ - canView: boolean; - load: LoadLspStatus; - } = $props(); - - const badgeClass: Record<Badge, string> = { - success: "badge-success", - warning: "badge-warning", - error: "badge-error", - neutral: "badge-ghost", - }; - - let servers = $state<readonly LspServerView[]>([]); - let loading = $state(false); - let error = $state<string | null>(null); - let loadedCwd = $state<string | null>(null); - let hasLoaded = $state(false); - let summary = $state(""); - - async function refresh() { - if (!canView) return; - loading = true; - error = null; - const result = await load(); - loading = false; - if (result === null) return; - hasLoaded = true; - if (result.ok) { - servers = viewLspServers(result.servers); - summary = summarizeServers(result.servers); - loadedCwd = result.cwd; - } else { - error = result.error; - } - } - - // (Re)load on mount and whenever the conversation's cwd changes. The LSP GET - // lazily spawns servers, so we avoid a redundant fetch when `cwd` resolves to - // the value we already loaded for. - $effect(() => { - const target = cwd; - const can = canView; - untrack(() => { - if (!can) return; - if (!hasLoaded || target !== loadedCwd) void refresh(); - }); - }); -</script> - -<div class="flex flex-col gap-2"> - <div class="flex items-center justify-between gap-2"> - <span class="text-xs opacity-70"> - {#if loading} - Resolving… - {:else if hasLoaded && loadedCwd !== null} - {summary} - {:else} - Language servers - {/if} - </span> - <button - type="button" - class="btn btn-ghost btn-xs" - disabled={!canView || loading} - onclick={() => refresh()} - aria-label="Refresh language server status" - > - {#if loading} - <span class="loading loading-spinner loading-xs"></span> - {:else} - Refresh - {/if} - </button> - </div> - - {#if !canView} - <p class="text-xs opacity-60">Open or start a conversation to see its language servers.</p> - {:else if error} - <p class="text-xs text-error">{error}</p> - {:else if hasLoaded && loadedCwd === null} - <p class="text-xs opacity-60"> - Set a working directory in the Model panel to enable language servers. - </p> - {:else if hasLoaded && servers.length === 0 && !loading} - <p class="text-xs opacity-60">No language servers configured for this directory.</p> - {:else} - <ul class="flex flex-col gap-2"> - {#each servers as server (server.id)} - <li class="flex flex-col gap-1 rounded-box bg-base-200 p-2 text-sm"> - <div class="flex items-center justify-between gap-2"> - <span class="font-medium">{server.name}</span> - <span class="badge badge-sm {badgeClass[server.badge]} gap-1"> - {#if server.busy} - <span class="loading loading-spinner loading-xs"></span> - {/if} - {server.statusLabel} - </span> - </div> - {#if server.extensionsLabel} - <span class="font-mono text-xs opacity-60">{server.extensionsLabel}</span> - {/if} - <span class="truncate font-mono text-xs opacity-50" title={server.root}>{server.root}</span> - {#if server.error} - <span class="font-mono text-xs text-error">{server.error}</span> - {/if} - </li> - {/each} - </ul> - {/if} -</div> |
