summaryrefslogtreecommitdiffhomepage
path: root/src/features/workspace
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/workspace')
-rw-r--r--src/features/workspace/index.ts14
-rw-r--r--src/features/workspace/logic/view-model.test.ts101
-rw-r--r--src/features/workspace/logic/view-model.ts130
-rw-r--r--src/features/workspace/ui/CwdField.svelte96
-rw-r--r--src/features/workspace/ui/LspStatusView.svelte127
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>