diff options
Diffstat (limited to 'src/features/cwd-lsp/logic')
| -rw-r--r-- | src/features/cwd-lsp/logic/view-model.test.ts | 101 | ||||
| -rw-r--r-- | src/features/cwd-lsp/logic/view-model.ts | 130 |
2 files changed, 231 insertions, 0 deletions
diff --git a/src/features/cwd-lsp/logic/view-model.test.ts b/src/features/cwd-lsp/logic/view-model.test.ts new file mode 100644 index 0000000..7bc0aad --- /dev/null +++ b/src/features/cwd-lsp/logic/view-model.test.ts @@ -0,0 +1,101 @@ +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/cwd-lsp/logic/view-model.ts b/src/features/cwd-lsp/logic/view-model.ts new file mode 100644 index 0000000..7642418 --- /dev/null +++ b/src/features/cwd-lsp/logic/view-model.ts @@ -0,0 +1,130 @@ +import type { LspServerInfo, LspServerState } from "@dispatch/transport-contract"; + +/** + * Pure core for the cwd-lsp feature — zero DOM, zero effects, zero Svelte. + * + * The cwd-lsp 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(", "); +} |
