diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 373328a9..d39b4772 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -5,7 +5,8 @@ name: Publish # - latest: run by hand on `main`; publishes each package whose version is not on npm yet # dev and latest also make GitHub Releases (scripts/github.mjs): `devel`, one prerelease with the # standalone binary replaced on every dev push, or `@v` per package released on -# latest, the CLI's with the binary and marked latest. canary stays npm-only. +# latest, the CLI's with the binary and marked latest. canary makes `nightly`, one prerelease +# with the desktop app replaced on every canary push. on: push: branches: [canary, dev] @@ -195,3 +196,76 @@ jobs: # codework.sh/install verifies each download against SHA256SUMS. - run: (cd release && sha256sum *.tar.gz > SHA256SUMS) - run: node scripts/github.mjs "$CHANNEL" release + + desktop: + needs: guard + if: github.ref_name == 'canary' + name: desktop (${{ matrix.target }}) + runs-on: ${{ matrix.runner }} + strategy: + fail-fast: true + matrix: + # Each on its own platform: electron-builder packages for the host, and macOS signs. + include: + - target: mac-arm64 + runner: macos-14 + args: --mac --arm64 + - target: linux-arm64 + runner: ubuntu-24.04-arm + args: --linux --arm64 + - target: linux-x64 + runner: ubuntu-24.04 + args: --linux --x64 + env: + CODEWORK_RELEASE_ID: ${{ needs.guard.outputs.id }} + steps: + - uses: actions/checkout@v6 + # The pacman target packs with bsdtar. + - if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libarchive-tools + - uses: voidzero-dev/setup-vp@v1.21.1 + with: + node-version: 24 + run-install: false + cache: false + # The app packages the web app and the app server it builds, so those install too. + - run: vp install --frozen-lockfile --filter '@codeworksh/desktop...' --filter '@codeworksh/server...' + - working-directory: apps/desktop + run: vp run package "$CHANNEL" ${{ matrix.args }} + # Installers only; electron-builder's update blockmaps and unpacked app stay behind. + - uses: actions/upload-artifact@v7 + with: + name: desktop-${{ matrix.target }} + path: | + apps/desktop/dist/codework-desktop-*.dmg + apps/desktop/dist/codework-desktop-*.zip + apps/desktop/dist/codework-desktop-*.AppImage + apps/desktop/dist/codework-desktop-*.deb + apps/desktop/dist/codework-desktop-*.pacman + if-no-files-found: error + retention-days: 1 + + # After npm, so `nightly` never names a commit whose packages did not ship. + nightly: + needs: [guard, publish, desktop] + if: github.ref_name == 'canary' + runs-on: ubuntu-latest + permissions: + contents: write + env: + CODEWORK_RELEASE_ID: ${{ needs.guard.outputs.id }} + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@v6 + - uses: voidzero-dev/setup-vp@v1.21.1 + with: + node-version: 24 + run-install: false + cache: false + - uses: actions/download-artifact@v8 + with: + pattern: desktop-* + path: release + merge-multiple: true + - run: (cd release && sha256sum codework-desktop-* > SHA256SUMS) + - run: node scripts/github.mjs canary release diff --git a/CLAUDE.md b/CLAUDE.md index e5aaf1fa..c6b1d36a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,13 +80,13 @@ Conventional Commit style with optional package scopes, e.g `feat(aikit): ...`, Every package is published by CI from `.github/workflows/publish.yml`, which runs `vp run release ` (`scripts/release.mjs`). It builds the TUI binaries, then publishes the packages in dependency order, derived from their `package.json` workspace dependencies (`scripts/workspace.mjs`), pinning each package to the exact versions uploaded in the same run. `scripts/publish.mjs` builds a single package, rewrites its manifest and publishes it from a temp dir. -- **canary**: every push to `canary`, without `vp check` or typecheck, for quick testing. Versions are `-canary...g`. +- **canary**: every push to `canary`, without `vp check` or typecheck, for quick testing. Versions are `-canary...g`. It also packages the desktop app (`apps/desktop`, `vp run package `) as "CodeWork Canary" for macOS arm64 and Linux x64/arm64, and replaces the `nightly` prerelease with it. - **dev**: every push to `dev`. Versions are `-dev.` (UTC). - **latest**: run the workflow by hand on `main`. It publishes each package whose manifest version is not on npm yet and skips the rest. -GitHub Releases (`scripts/github.mjs`): dev replaces the `devel` prerelease with the CLI binaries; latest makes a `@v` release per package not released yet, the CLI's carrying the binaries. canary has none. +GitHub Releases (`scripts/github.mjs`): dev replaces the `devel` prerelease with the CLI binaries; latest makes a `@v` release per package not released yet, the CLI's carrying the binaries. canary replaces the `nightly` prerelease with the desktop app. -Versions are bumped locally with `vp run bump [version]`, which runs `bumpp` with the package's `bump.config.ts` and commits and tags `@v` without pushing. `vp run bump tui` moves the launcher and its three platform packages together. +Versions are bumped locally with `vp run bump [version]`, which runs `bumpp` with the package's `bump.config.ts` and commits and tags `@v` without pushing. `vp run bump tui` moves the launcher and its three platform packages together. Flow: diff --git a/apps/contract/package.json b/apps/contract/package.json new file mode 100644 index 00000000..7bf09ddc --- /dev/null +++ b/apps/contract/package.json @@ -0,0 +1,20 @@ +{ + "name": "@codeworksh/contract", + "version": "0.0.0", + "private": true, + "description": "The desktop app server's RPC contract, shared by the app server and the web app.", + "type": "module", + "exports": { + ".": "./src/contract.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@codeworksh/widget": "workspace:*", + "effect": "4.0.5" + }, + "devDependencies": { + "typescript": "catalog:" + } +} diff --git a/apps/server/src/contract.ts b/apps/contract/src/contract.ts similarity index 98% rename from apps/server/src/contract.ts rename to apps/contract/src/contract.ts index 3133d243..8cf728ed 100644 --- a/apps/server/src/contract.ts +++ b/apps/contract/src/contract.ts @@ -27,8 +27,10 @@ export class CodeworkUnavailable extends Schema.TaggedError message: Schema.String, }) {} -/** The models could not be listed: a settings file or the model catalog is broken. */ +/** The models could not be listed: a settings file or the model catalog, at `path`, is broken. */ export class ModelsError extends Schema.TaggedError()("Server.ModelsError", { + source: Schema.Literals(["settings", "catalog"]), + path: Schema.String, message: Schema.String, }) {} diff --git a/apps/contract/tsconfig.json b/apps/contract/tsconfig.json new file mode 100644 index 00000000..db29c6c1 --- /dev/null +++ b/apps/contract/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "tsBuildInfoFile": "../../.cache/typescript/contract.tsbuildinfo" + }, + "include": ["src"] +} diff --git a/apps/desktop/bump.config.ts b/apps/desktop/bump.config.ts new file mode 100644 index 00000000..1e796b86 --- /dev/null +++ b/apps/desktop/bump.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "bumpp"; + +// Release config for the desktop app, loaded automatically by `bumpp`. Its version is its own; +// canary and dev builds add their run id to it as the npm packages do. +export default defineConfig({ + tag: "desktop@v%s", + commit: "release: desktop@v%s", + push: false, +}); diff --git a/apps/desktop/electron-builder.config.ts b/apps/desktop/electron-builder.config.ts new file mode 100644 index 00000000..05d469cc --- /dev/null +++ b/apps/desktop/electron-builder.config.ts @@ -0,0 +1,65 @@ +import type { Configuration } from "electron-builder"; + +// One identity per channel, so channels install side by side with their own data. The version +// is the run's, passed by scripts/package.mjs. +const CHANNELS = { + latest: { productName: "CodeWork", appId: "sh.codework.desktop", packageName: "codework-desktop" }, + dev: { productName: "CodeWork Dev", appId: "sh.codework.desktop.dev", packageName: "codework-desktop-dev" }, + canary: { + productName: "CodeWork Canary", + appId: "sh.codework.desktop.canary", + packageName: "codework-desktop-canary", + }, +} as const; + +const channel = process.env.CODEWORK_CHANNEL; +if (channel !== "latest" && channel !== "dev" && channel !== "canary") { + throw new Error(`CODEWORK_CHANNEL must be latest, dev or canary, not ${channel}`); +} +const { productName, appId, packageName } = CHANNELS[channel]; + +export default { + appId, + productName, + // The same name on every channel; the release a file sits in says which channel it is. + artifactName: "codework-desktop-${os}-${arch}.${ext}", + // Nothing native to rebuild: the app server uses node:sqlite. + npmRebuild: false, + copyright: "Copyright © CodeWork", + directories: { output: "dist", buildResources: "resources" }, + extraMetadata: { name: packageName, productName, homepage: "https://www.codework.sh" }, + // The app is its two bundles and its icon; Electron is the only dependency, and it is the runtime. + files: ["out/*.cjs", "resources/icon.png", "package.json"], + // The app server runs as its own process from Resources, so it sits outside the asar archive. + extraResources: [ + { from: "../server/dist/index.mjs", to: "server/index.mjs" }, + { from: "../web/dist", to: "web" }, + ], + mac: { + target: [ + { target: "dmg", arch: "arm64" }, + { target: "zip", arch: "arm64" }, + ], + category: "public.app-category.developer-tools", + icon: "resources/icon.png", + // Ad-hoc: Apple silicon runs nothing unsigned. A downloaded build still needs its quarantine + // flag removed until builds are signed and notarized. + identity: "-", + hardenedRuntime: false, + }, + linux: { + // Each format names x64 and arm64 its own way (x86_64, amd64, aarch64); one name for all, + // from the arch scripts/package.mjs builds for. + artifactName: "codework-desktop-linux-${env.CODEWORK_ARCH}.${ext}", + target: ["AppImage", "deb", "pacman"], + category: "Development", + icon: "resources/icon.png", + maintainer: "CodeWork", + executableName: packageName, + }, + deb: { packageName }, + pacman: { packageName }, + // The static AppImage runtime, so it runs without libfuse2. + toolsets: { appimage: "1.0.3" }, + publish: null, +} satisfies Configuration; diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f33c4f5e..df930c6d 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,20 +1,22 @@ { "name": "@codeworksh/desktop", - "version": "0.0.0", + "version": "0.3.0", "private": true, + "description": "The CodeWork desktop app.", "type": "module", "main": "out/main.cjs", "scripts": { "build": "vp pack", "dev": "node scripts/dev.mjs", "start": "node scripts/start-electron.mjs", - "typecheck": "tsc --noEmit" - }, - "dependencies": { - "electron": "44.4.2" + "typecheck": "tsc --noEmit", + "package": "node scripts/package.mjs" }, "devDependencies": { "@types/node": "catalog:", + "bumpp": "^12.3.0", + "electron": "44.4.2", + "electron-builder": "^26.15.3", "typescript": "catalog:", "vite-plus": "catalog:" } diff --git a/apps/desktop/scripts/package.mjs b/apps/desktop/scripts/package.mjs new file mode 100644 index 00000000..0c1a8a3b --- /dev/null +++ b/apps/desktop/scripts/package.mjs @@ -0,0 +1,47 @@ +// `vp run package [electron-builder args]`: builds the web app, the app server +// and this app, then packages them for the channel under the run's version, e.g. `--mac --arm64 --dir`. +// In CI the version carries the run id; locally a dry-run id stands in. +import { spawnSync } from "node:child_process"; +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { releaseId, releaseVersion } from "../../../scripts/version.mjs"; + +const desktopDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); + +const [channel, ...builderArgs] = process.argv.slice(2); +if (!["canary", "dev", "latest"].includes(channel)) { + console.error("Usage: vp run package [electron-builder args]"); + process.exit(1); +} + +const run = (command, args, cwd, env = process.env) => { + const result = spawnSync(command, args, { cwd, env, stdio: "inherit" }); + if (result.status !== 0) process.exit(result.status ?? 1); +}; + +// One arch per run: the one asked for, else this machine's (Node names them as the file names do). +const arch = builderArgs.includes("--arm64") ? "arm64" : builderArgs.includes("--x64") ? "x64" : process.arch; + +const { version: base } = JSON.parse(readFileSync(path.join(desktopDir, "package.json"), "utf8")); +const version = releaseVersion(base, channel, releaseId(channel, { dryRun: !process.env.CI })); + +run("vp", ["build"], path.resolve(desktopDir, "../web")); +run("vp", ["pack"], path.resolve(desktopDir, "../server")); +run("vp", ["pack"], desktopDir); +run( + "pnpm", + [ + "exec", + "electron-builder", + "--config", + "electron-builder.config.ts", + `-c.extraMetadata.version=${version}`, + "--publish", + "never", + ...builderArgs, + ], + desktopDir, + { ...process.env, CODEWORK_CHANNEL: channel, CODEWORK_ARCH: arch }, +); diff --git a/apps/desktop/scripts/start-electron.mjs b/apps/desktop/scripts/start-electron.mjs index e9f75831..65928a80 100644 --- a/apps/desktop/scripts/start-electron.mjs +++ b/apps/desktop/scripts/start-electron.mjs @@ -7,6 +7,8 @@ import { fileURLToPath } from "node:url"; const desktopDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const electronBinary = createRequire(import.meta.url)("electron"); const APP_NAME = "CodeWork"; +// Its own identity, so a local run never shares data or Dock state with an installed channel. +const APP_ID = "sh.codework.desktop.local"; // macOS names the Dock icon after the running bundle's Info.plist, so on macOS // Electron runs from a renamed copy of Electron.app, rebuilt when Electron changes. @@ -15,7 +17,8 @@ function brandedElectron() { const target = path.join(desktopDir, ".electron-runtime", `${APP_NAME}.app`); const marker = path.join(desktopDir, ".electron-runtime", "source"); const binary = path.join(target, "Contents", "MacOS", "Electron"); - if (existsSync(marker) && readFileSync(marker, "utf8") === source) return binary; + const stamp = `${source}\n${APP_ID}`; + if (existsSync(marker) && readFileSync(marker, "utf8") === stamp) return binary; rmSync(target, { recursive: true, force: true }); // Keeps the frameworks' relative symlinks; absolute ones crash the helper processes. @@ -24,11 +27,11 @@ function brandedElectron() { for (const [key, value] of [ ["CFBundleName", APP_NAME], ["CFBundleDisplayName", APP_NAME], - ["CFBundleIdentifier", "sh.codework.desktop.dev"], + ["CFBundleIdentifier", APP_ID], ]) execFileSync("plutil", ["-replace", key, "-string", value, plist]); execFileSync("codesign", ["--force", "--deep", "--sign", "-", target]); - writeFileSync(marker, source); + writeFileSync(marker, stamp); return binary; } diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index b6827142..74e04584 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -4,3 +4,5 @@ export const SERVER_CHANNEL = "codework:server"; export const HOME_CHANNEL = "codework:home"; /** Invoke: opens the native folder dialog (Finder on macOS); resolves to the chosen path or null. */ export const PICK_FOLDER_CHANNEL = "codework:pick-folder"; +/** Invoke: shows a file in Finder (Explorer on Windows), selected. */ +export const REVEAL_CHANNEL = "codework:reveal"; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 61b85477..5b5ca17d 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -1,7 +1,7 @@ -import { app, BrowserWindow, dialog, ipcMain, net, protocol } from "electron"; +import { app, BrowserWindow, dialog, ipcMain, net, protocol, shell } from "electron"; import { readFile, stat } from "node:fs/promises"; import path from "node:path"; -import { HOME_CHANNEL, PICK_FOLDER_CHANNEL } from "./ipc.ts"; +import { HOME_CHANNEL, PICK_FOLDER_CHANNEL, REVEAL_CHANNEL } from "./ipc.ts"; import { startServer } from "./server.ts"; const SCHEME = app.isPackaged ? "codework" : "codework-dev"; @@ -9,9 +9,12 @@ const HOST = "app"; const APP_URL = `${SCHEME}://${HOST}/`; // When set (web dev server), the scheme proxies to it; otherwise the bundled -// `@codeworksh/web` build is served from disk. +// `@codeworksh/web` build is served from disk: the app's Resources when packaged, +// else the workspace beside this app. const DEV_SERVER_URL = process.env.CODEWORK_WEB_URL; -const CLIENT_ASSETS_DIR = process.env.CODEWORK_WEB_DIR ?? path.resolve(app.getAppPath(), "../web/dist"); +const CLIENT_ASSETS_DIR = + process.env.CODEWORK_WEB_DIR ?? + (app.isPackaged ? path.join(process.resourcesPath, "web") : path.resolve(app.getAppPath(), "../web/dist")); // The 3D brand mark on Apple's icon grid. A packaged build will carry it as an .icns; until then // it is set at runtime, or the Dock shows Electron's own icon. const ICON = path.join(app.getAppPath(), "resources/icon.png"); @@ -146,6 +149,7 @@ void app.whenReady().then(async () => { const result = await (window === null ? dialog.showOpenDialog(options) : dialog.showOpenDialog(window, options)); return result.canceled ? null : (result.filePaths[0] ?? null); }); + ipcMain.handle(REVEAL_CHANNEL, (_event, file: string) => shell.showItemInFolder(file)); await startServer(); createMainWindow(); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 5dcb7e75..8fec9cb6 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -1,5 +1,5 @@ import { contextBridge, ipcRenderer } from "electron"; -import { HOME_CHANNEL, PICK_FOLDER_CHANNEL, SERVER_CHANNEL } from "./ipc.ts"; +import { HOME_CHANNEL, PICK_FOLDER_CHANNEL, REVEAL_CHANNEL, SERVER_CHANNEL } from "./ipc.ts"; contextBridge.exposeInMainWorld("desktopBridge", { platform: process.platform, @@ -7,4 +7,5 @@ contextBridge.exposeInMainWorld("desktopBridge", { server: ipcRenderer.sendSync(SERVER_CHANNEL) as string, home: ipcRenderer.sendSync(HOME_CHANNEL) as string, pickFolder: (from: string) => ipcRenderer.invoke(PICK_FOLDER_CHANNEL, from) as Promise, + reveal: (path: string) => ipcRenderer.invoke(REVEAL_CHANNEL, path) as Promise, }); diff --git a/apps/desktop/src/server.ts b/apps/desktop/src/server.ts index 515721b3..406729c6 100644 --- a/apps/desktop/src/server.ts +++ b/apps/desktop/src/server.ts @@ -4,8 +4,10 @@ import path from "node:path"; import { createInterface } from "node:readline"; import { SERVER_CHANNEL } from "./ipc.ts"; -// Dev layout: the server bundle sits beside this app in the workspace. -const ENTRY = path.resolve(app.getAppPath(), "../server/dist/index.mjs"); +// The server bundle: in the app's Resources when packaged, else beside this app in the workspace. +const ENTRY = app.isPackaged + ? path.join(process.resourcesPath, "server", "index.mjs") + : path.resolve(app.getAppPath(), "../server/dist/index.mjs"); // How long the server gets to stop by itself on quit before it is killed. const STOP_GRACE_MS = 5000; diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index 64f99a91..fe14fcdd 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -5,5 +5,5 @@ "lib": ["ES2023", "DOM"], "tsBuildInfoFile": "../../.cache/typescript/desktop.tsbuildinfo" }, - "include": ["src", "vite.config.ts"] + "include": ["src", "vite.config.ts", "electron-builder.config.ts", "bump.config.ts"] } diff --git a/apps/server/package.json b/apps/server/package.json index 8ae212df..323f8d91 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -3,9 +3,6 @@ "version": "0.0.0", "private": true, "type": "module", - "exports": { - "./contract": "./src/contract.ts" - }, "scripts": { "build": "vp pack", "dev": "vp pack --watch", @@ -13,6 +10,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@codeworksh/contract": "workspace:*", "@codeworksh/web": "workspace:*", "@codeworksh/widget": "workspace:*", "@effect/platform-node": "4.0.5", diff --git a/apps/server/src/cli.ts b/apps/server/src/cli.ts index 891760c7..a3f1ac26 100644 --- a/apps/server/src/cli.ts +++ b/apps/server/src/cli.ts @@ -12,13 +12,9 @@ import { ServersError, type ServerStatus, type ServerUpdate, -} from "./contract.ts"; +} from "@codeworksh/contract"; import { Servers } from "./servers.ts"; -/** Splits typed arguments as a shell would for plain words and quotes: `--name "a b"` is two. */ -const splitArgs = (input: string) => - [...input.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)].map((match) => match[1] ?? match[2] ?? match[3] ?? ""); - // Where the install script puts the binary unless told otherwise. const installDir = Config.String("CODEWORK_INSTALL_DIR").pipe( Config.withDefault(path.join(os.homedir(), ".codework", "bin")), @@ -64,14 +60,27 @@ const onPath = Effect.gen(function* () { .findLast((line) => line.startsWith("/")); }).pipe(Effect.orElseSucceed(() => undefined)); -const probe = (file: string, custom: boolean) => - run(file, ["--version"], "10 seconds").pipe( +interface Command { + readonly file: string; + /** Arguments that go before the CLI's own, as for a source entry run by `node`. */ + readonly prefix: ReadonlyArray; +} + +/** A chosen executable, split as typed; a found binary's path is never split, as it may hold spaces. */ +const split = (executable: string): Command => { + const [file = executable, ...prefix] = Servers.splitArgs(executable); + return { file, prefix }; +}; + +// `executable` is shown as `path`: a binary's path, or the whole command as stored. +const probe = ({ file, prefix }: Command, executable: string, custom: boolean) => + run(file, [...prefix, "--version"], "10 seconds").pipe( Effect.map(({ output, code }): CliInstall => { const version = /\bv?(\d+\.\d+\.\d+\S*)/.exec(output)?.[1]; - if (code === 0 && version !== undefined) return { path: file, custom, version }; - return { path: file, custom, failure: reason(output, code) }; + if (code === 0 && version !== undefined) return { path: executable, custom, version }; + return { path: executable, custom, failure: reason(output, code) }; }), - Effect.catch((error) => Effect.succeed({ path: file, custom, failure: error.message })), + Effect.catch((error) => Effect.succeed({ path: executable, custom, failure: error.message })), ); const exists = (file: string) => @@ -92,7 +101,7 @@ const found = Effect.gen(function* () { let broken: CliInstall | null = null; for (const file of candidates) { if (!(yield* exists(file))) continue; - const install = yield* probe(file, false); + const install = yield* probe({ file, prefix: [] }, file, false); if ("version" in install) return install; broken ??= install; } @@ -100,14 +109,16 @@ const found = Effect.gen(function* () { }); /** The `codework` a server runs: the one chosen for it, else the one found. */ -const executableOf = (row: Servers.Row, foundOnce: typeof found) => - row.executable === null - ? foundOnce - : Effect.flatMap(exists(row.executable), (present) => - present - ? probe(row.executable!, true) - : Effect.succeed({ path: row.executable!, custom: true, failure: "No file at this path." }), - ); +const executableOf = (row: Servers.Row, foundOnce: typeof found) => { + const { executable } = row; + if (executable === null) return foundOnce; + const command = split(executable); + return Effect.flatMap(exists(command.file), (present) => + present + ? probe(command, executable, true) + : Effect.succeed({ path: executable, custom: true, failure: "No file at this path." }), + ); +}; const alive = (pid: number) => Effect.sync(() => { @@ -220,8 +231,9 @@ export const handlers = { return yield* new ServersError({ message: "No working codework CLI was found." }); const env = Object.fromEntries(row.env.map(({ name, value }) => [name, value])); // `stop` takes no server options, so the arguments go only to what starts a server. - const args = action === "stop" ? [] : splitArgs(row.args); - const { output, code } = yield* run(cli.path, ["service", action, ...args], "30 seconds", { + const args = action === "stop" ? [] : Servers.splitArgs(row.args); + const { file, prefix } = row.executable === null ? { file: cli.path, prefix: [] } : split(row.executable); + const { output, code } = yield* run(file, [...prefix, "service", action, ...args], "30 seconds", { ...env, CODEWORK_HOME_DIR: servers.homeOf(row).path, }).pipe( diff --git a/apps/server/src/codework.ts b/apps/server/src/codework.ts index 97897234..ac05bd31 100644 --- a/apps/server/src/codework.ts +++ b/apps/server/src/codework.ts @@ -6,7 +6,7 @@ import { RpcClient, RpcSerialization } from "effect/rpc"; import { Socket } from "effect/socket"; /* @effect-diagnostics nodeBuiltinImport:off -- a home's registration is a file path. */ import path from "node:path"; -import { CodeworkUnavailable } from "./contract.ts"; +import { CodeworkUnavailable } from "@codeworksh/contract"; import { Servers } from "./servers.ts"; export type SessionInfo = Mirror.SessionInfo; diff --git a/apps/server/src/desk.ts b/apps/server/src/desk.ts index 4bfd765c..b3a7de76 100644 --- a/apps/server/src/desk.ts +++ b/apps/server/src/desk.ts @@ -1,6 +1,6 @@ import { Clock, Effect, Schema } from "effect"; import { SqlClient } from "effect/sql"; -import { Contract, type Instance } from "./contract.ts"; +import { Contract, type Instance } from "@codeworksh/contract"; import { Db } from "./db.ts"; // SQLite has no booleans; `pinned` is stored as 0 or 1. diff --git a/apps/server/src/handlers.ts b/apps/server/src/handlers.ts index 3df68ba3..5e745db5 100644 --- a/apps/server/src/handlers.ts +++ b/apps/server/src/handlers.ts @@ -1,7 +1,7 @@ import { Effect } from "effect"; import { Cli } from "./cli.ts"; import { Codework } from "./codework.ts"; -import { Contract, ModelsError, type Project } from "./contract.ts"; +import { Contract, ModelsError, type Project } from "@codeworksh/contract"; import { Desk } from "./desk.ts"; import { Event } from "./event.ts"; import { Kv } from "./kv.ts"; @@ -57,12 +57,18 @@ export const layer = Contract.Api.toLayer( client("model.list", hostDir === undefined ? {} : { hostDir }).pipe( Effect.map((list) => ({ list })), Effect.catchTags({ - SettingsError: (error) => Effect.succeed({ failed: `${error.path}: ${error.detail}` }), - "Runner.ModelCatalogError": (error) => Effect.succeed({ failed: error.detail }), + SettingsError: (error) => + Effect.succeed({ + failed: new ModelsError({ source: "settings", path: error.path, message: error.detail }), + }), + "Runner.ModelCatalogError": (error) => + Effect.succeed({ + failed: new ModelsError({ source: "catalog", path: error.path, message: error.detail }), + }), }), ), ); - if ("failed" in listed) return yield* new ModelsError({ message: listed.failed }); + if ("failed" in listed) return yield* listed.failed; return listed.list; }), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 9b42ff14..9c9d2926 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -6,7 +6,7 @@ import { RpcSerialization, RpcServer } from "effect/rpc"; import { createServer } from "node:http"; import type { Socket } from "node:net"; import { Codework } from "./codework.ts"; -import { Contract } from "./contract.ts"; +import { Contract } from "@codeworksh/contract"; import { Db } from "./db.ts"; import { Event } from "./event.ts"; import { Handlers } from "./handlers.ts"; diff --git a/apps/server/src/servers.ts b/apps/server/src/servers.ts index c6dfb675..9178aa0f 100644 --- a/apps/server/src/servers.ts +++ b/apps/server/src/servers.ts @@ -1,9 +1,9 @@ -import { Clock, Config, Context, Effect, Layer, Schema, Stream, SubscriptionRef } from "effect"; +import { Clock, Config, Context, Effect, FileSystem, Layer, Schema, Stream, SubscriptionRef } from "effect"; import { SqlClient } from "effect/sql"; /* @effect-diagnostics nodeBuiltinImport:off -- paths typed in Settings expand against the OS home directory. */ import os from "node:os"; import path from "node:path"; -import { type CliEnvVar, LOCAL_SERVER, type ServerAdd, ServersError, type ServerUpdate } from "./contract.ts"; +import { type CliEnvVar, LOCAL_SERVER, type ServerAdd, ServersError, type ServerUpdate } from "@codeworksh/contract"; /** The same home the CLI resolves: CODEWORK_HOME_DIR, else ~/.codework. Only the built-in server falls back to it. */ const cliHome = Config.String("CODEWORK_HOME_DIR").pipe(Config.withDefault(path.join(os.homedir(), ".codework"))); @@ -14,7 +14,12 @@ export const StoredEnv = Schema.Array( ); export type StoredEnv = typeof StoredEnv.Type; -/** A server as stored. A `null` executable or home is the one found. */ +/** Splits typed arguments as a shell would for plain words and quotes: `--name "a b"` is two. */ +export const splitArgs = (input: string) => + [...input.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)].map((match) => match[1] ?? match[2] ?? match[3] ?? ""); + +/** A server as stored. A `null` executable or home is the one found. An executable is a path, or a + * command whose first word is one (`node --conditions=development …/index.ts`, running from source). */ export interface Row { readonly id: string; readonly kind: "local"; @@ -42,6 +47,24 @@ const chosenPath = (input: string | null) => return path.normalize(expanded); }); +const quoted = (word: string) => (/\s/.test(word) ? `"${word}"` : word); + +/** + * An executable typed in Settings: a path as {@link chosenPath}, optionally followed by arguments. + * A typed path naming a file is that file even with spaces in it, as an install folder may have. + */ +const chosenCommand = (fs: FileSystem.FileSystem, input: string | null) => + Effect.gen(function* () { + const whole = yield* chosenPath(input).pipe(Effect.orElseSucceed(() => null)); + if (whole !== null && (yield* fs.exists(whole).pipe(Effect.orElseSucceed(() => false)))) return quoted(whole); + const [program, ...rest] = splitArgs(input ?? ""); + const file = yield* chosenPath(program ?? null); + if (file === null) return null; + // `~/…` arguments expand too, so a source entry can be typed as it reads in a terminal. + const args = rest.map((word) => (word.startsWith("~/") ? path.join(os.homedir(), word.slice(1)) : word)); + return [file, ...args].map(quoted).join(" "); + }); + const VARIABLE_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; /** @@ -98,6 +121,7 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; + const fs = yield* FileSystem.FileSystem; const fallback = yield* cliHome.pipe(Effect.orDie); const list = sql & { kind: string; env: string; isDefault: number }>` @@ -174,7 +198,7 @@ export const layer = Layer.effect( id, kind: "local", name: yield* name(input.name), - executable: yield* chosenPath(input.executable), + executable: yield* chosenCommand(fs, input.executable), home, args: input.args.trim(), env: "[]", @@ -193,7 +217,7 @@ export const layer = Layer.effect( const row = yield* get(id); const fields: Record = {}; if (patch.name !== undefined) fields.name = yield* name(patch.name); - if (patch.executable !== undefined) fields.executable = yield* chosenPath(patch.executable); + if (patch.executable !== undefined) fields.executable = yield* chosenCommand(fs, patch.executable); if (patch.args !== undefined) fields.args = patch.args.trim(); if (patch.env !== undefined) fields.env = JSON.stringify(yield* nextEnv(patch.env, row.env)); if (patch.home !== undefined) { diff --git a/apps/server/src/workspaces.ts b/apps/server/src/workspaces.ts index 7dfa48b9..0ab83c31 100644 --- a/apps/server/src/workspaces.ts +++ b/apps/server/src/workspaces.ts @@ -7,7 +7,7 @@ import { type WorkspaceId, type WorkspaceOrder, type WorkspaceUpdate, -} from "./contract.ts"; +} from "@codeworksh/contract"; // webwm tag bits a workspace may hold; also the Cmd+1–9 keys. const MAX = 9; diff --git a/apps/server/test/desk.test.ts b/apps/server/test/desk.test.ts index dec33507..3afce05b 100644 --- a/apps/server/test/desk.test.ts +++ b/apps/server/test/desk.test.ts @@ -9,7 +9,7 @@ import { join } from "node:path"; import { createInterface } from "node:readline"; import { fileURLToPath } from "node:url"; import { afterAll, beforeAll, expect, it } from "vite-plus/test"; -import { Contract } from "../src/contract.ts"; +import { Contract } from "@codeworksh/contract"; const entry = fileURLToPath(new URL("../src/index.ts", import.meta.url)); const home = mkdtempSync(join(tmpdir(), "codework-desk-")); diff --git a/apps/server/vite.config.ts b/apps/server/vite.config.ts index 202c8ec8..2691239d 100644 --- a/apps/server/vite.config.ts +++ b/apps/server/vite.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from "vite-plus"; -// Electron runs this bundle as a Node process (ELECTRON_RUN_AS_NODE), resolving -// dependencies from node_modules beside it, so they stay external. +// Electron runs this bundle as a Node process (ELECTRON_RUN_AS_NODE). A packaged app ships it +// without node_modules, so every dependency, workspace packages included, is inlined. export default defineConfig({ pack: [ { @@ -11,6 +11,11 @@ export default defineConfig({ sourcemap: true, entry: ["src/index.ts"], clean: true, + platform: "node", + deps: { + alwaysBundle: [/^(?!node:)/], + onlyBundle: false, + }, }, ], test: { diff --git a/apps/web/package.json b/apps/web/package.json index 06b999ab..cf7421c9 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,7 +13,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@codeworksh/server": "workspace:*", + "@codeworksh/contract": "workspace:*", "@codeworksh/widget": "workspace:*", "@effect/atom-react": "4.0.5", "@legendapp/list": "3.3.5", @@ -22,8 +22,8 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "hast-util-to-jsx-runtime": "2.3.6", "effect": "4.0.5", + "hast-util-to-jsx-runtime": "2.3.6", "lucide-react": "^1.52.0", "radix-ui": "^1.7.0", "react": "^19.3.0", diff --git a/apps/web/src/components/banner.tsx b/apps/web/src/components/banner.tsx index f68a8b00..839f60dc 100644 --- a/apps/web/src/components/banner.tsx +++ b/apps/web/src/components/banner.tsx @@ -1,57 +1,93 @@ -import { CircleAlert, Clock, X } from "lucide-react"; +import { CircleAlert, Clock, type LucideIcon, TriangleAlert, X } from "lucide-react"; /** - * Something the reader should know before writing more: a short title and a one-line description. - * `expired` is a state the session is in, not a fault, so it reads neutral rather than red. + * Something the reader should know before writing more: a short title, and a line only when it adds + * a fact. `expired` is a state the session is in, not a fault, so it reads neutral rather than red; + * `warning` is a state outside the session (its server is down) that one action may fix. */ export interface Notice { readonly title: string; - readonly description: string; - readonly tone: "error" | "expired"; + readonly description?: string; + readonly tone: "error" | "warning" | "expired"; + readonly action?: NoticeAction; } +export interface NoticeAction { + readonly label: string; + readonly icon?: LucideIcon; + readonly disabled?: boolean; + readonly onClick: () => void; +} + +const tones: Record = { + // A faint tint and border of the tone's color over the composer's tray surface. + error: { + icon: CircleAlert, + text: "text-destructive", + surface: "border-destructive/30 bg-[color-mix(in_oklab,var(--color-destructive)_8%,var(--color-frame))]", + }, + warning: { + icon: TriangleAlert, + text: "text-warning", + surface: "border-warning/30 bg-[color-mix(in_oklab,var(--color-warning)_8%,var(--color-frame))]", + }, + expired: { + icon: Clock, + text: "text-warning", + surface: "border-warning/30 bg-[color-mix(in_oklab,var(--color-warning)_8%,var(--color-frame))]", + }, +}; + /** - * A notice tucked behind the composer's top edge, as its tray is tucked under the bottom one: subtle, - * attached to what it is about, and dismissible. The description is clamped to two lines, whole on hover. + * A notice tucked behind the composer's top edge, as its tray is tucked under the bottom one: subtle + * and attached to what it is about. Dismissible when `onDismiss` is given; a notice about a state + * clears itself instead. The description is clamped to two lines, whole on hover. */ export function ComposerNotice({ title, description, tone, + action, onDismiss, }: Notice & { - readonly onDismiss: () => void; + readonly onDismiss?: () => void; }) { - const Icon = tone === "expired" ? Clock : CircleAlert; - // A faint tint and border of the tone's color over the composer's tray surface. - const colors = - tone === "expired" - ? "border-warning/30 bg-[color-mix(in_oklab,var(--color-warning)_8%,var(--color-frame))]" - : "border-destructive/30 bg-[color-mix(in_oklab,var(--color-destructive)_8%,var(--color-frame))]"; + const { icon: Icon, text, surface } = tones[tone]; return (
- +

{title}

-

- {description} -

+ {description !== undefined && ( +

+ {description} +

+ )}
- + {action !== undefined && ( + + )} + {onDismiss !== undefined && ( + + )}
); } diff --git a/apps/web/src/components/model.tsx b/apps/web/src/components/model.tsx index 23f065a0..d72a0ab0 100644 --- a/apps/web/src/components/model.tsx +++ b/apps/web/src/components/model.tsx @@ -1,10 +1,10 @@ import { type ModelProvider, ThinkingLevel } from "@codeworksh/widget/codework"; -import { useAtomValue } from "@effect/atom-react"; +import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; import { Command as CommandPrimitive } from "cmdk"; -import { Option, Schema } from "effect"; +import { Cause, Option, Schema } from "effect"; import { AsyncResult, Atom } from "effect/reactivity"; -import { Brain, ChevronDown, Search } from "lucide-react"; -import { useMemo, useRef, useState } from "react"; +import { Brain, ChevronDown, FileSearch, RotateCw, Search } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { DropdownMenu, @@ -15,6 +15,9 @@ import { } from "@/components/ui/dropdown-menu"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Server } from "../kernel/rpc"; +import { serversAtom } from "../settings/servers/local"; +import { fixIcons, problemOf, useFix } from "../settings/servers/problem"; +import type { Notice } from "./banner"; import { ProviderIcon } from "./provider"; export interface ModelRef { @@ -59,6 +62,71 @@ const modelsAtom = Atom.family((hostDir: string | undefined) => Server.query("models.list", hostDir === undefined ? {} : { hostDir }), ); +/** + * Why the models for `hostDir` can't be listed, as the composer's notice; undefined while they can. + * A down server is named from its own status, which is checked again when it goes down and comes + * back. While it fails the list is asked for again every few seconds, so the notice clears itself + * and the pickers come back once whatever was wrong is fixed, here or elsewhere. + */ +export function useModelsNotice(hostDir: string | undefined): Notice | undefined { + const result = useAtomValue(modelsAtom(hostDir)); + const refresh = useAtomRefresh(modelsAtom(hostDir)); + const servers = useAtomValue(serversAtom); + const refreshServers = useAtomRefresh(serversAtom); + const fix = useFix(refresh); + const error = AsyncResult.isFailure(result) ? Option.getOrUndefined(Cause.findErrorOption(result.cause)) : undefined; + const failed = AsyncResult.isFailure(result); + const down = error?._tag === "Server.CodeworkUnavailable"; + + useEffect(() => { + if (!failed || result.waiting) return; + const timer = setTimeout(refresh, 5_000); + return () => clearTimeout(timer); + }, [failed, result, refresh]); + // The server's status costs a login shell and the CLI to read, so only on the way down and back up. + const wasDown = useRef(false); + useEffect(() => { + if (down !== wasDown.current) refreshServers(); + wasDown.current = down; + }, [down, refreshServers]); + + if (!failed) return undefined; + const retry = { label: "Try Again", icon: RotateCw, disabled: result.waiting, onClick: refresh }; + if (error?._tag === "Server.ModelsError") { + if (error.source === "catalog") + return { + title: "Model Catalog Missing or Unavailable", + description: error.message, + tone: "error", + action: retry, + }; + const { reveal } = window.desktopBridge ?? {}; + return { + title: "Settings Error", + description: error.message, + tone: "error", + ...(reveal === undefined + ? {} + : { action: { label: "Reveal", icon: FileSearch, onClick: () => void reveal(error.path) } }), + }; + } + const server = down && AsyncResult.isSuccess(servers) ? servers.value.find((one) => one.isDefault) : undefined; + const problem = server === undefined ? undefined : problemOf(server); + if (server === undefined || problem === undefined) + return { title: "Couldn't Load Models", tone: "error", action: retry }; + return { + title: problem.title, + ...((fix.error ?? problem.detail) === undefined ? {} : { description: fix.error ?? problem.detail }), + tone: "warning", + action: { + label: fix.label(problem), + icon: fixIcons[problem.fix], + disabled: fix.running, + onClick: () => fix.run(server, problem), + }, + }; +} + // The composer row's pickers: muted text that brightens on hover, like the prompt's own controls. const trigger = "flex h-7 min-w-0 shrink-0 items-center gap-1.5 rounded-[8px] px-2.5 outline-none press hover:bg-ink/5 hover:text-ink focus-visible:bg-ink/5 data-[state=open]:bg-ink/5 data-[state=open]:text-ink"; @@ -131,15 +199,11 @@ export function ModelPicker({ // The settings default can name a provider this server cannot call; it reads muted, not chosen. const unavailable = current !== undefined && list !== undefined && found?.provider.available !== true; - const label = - found?.model.name ?? current?.id ?? (AsyncResult.isFailure(result) ? "Models unavailable" : "Choose model"); - const empty = AsyncResult.isFailure(result) - ? "Could not list models." - : list === undefined - ? "Loading models…" - : providers.length === 0 - ? "No provider is signed in." - : "No models found"; + // Why is the composer's notice; the picker only reads as unusable until it clears. + const failed = AsyncResult.isFailure(result); + const label = failed ? "Unavailable" : (found?.model.name ?? current?.id ?? "Choose model"); + const empty = + list === undefined ? "Loading models…" : providers.length === 0 ? "No provider is signed in." : "No models found"; return ( - {current !== undefined && ( + {current !== undefined && !failed && ( (null); - // What settings would run this model at, marked so the reader knows what leaving it alone means. - const fallback = list === undefined ? undefined : clamp(list.default.thinkingLevel, levels); const shown = list === undefined ? undefined : clamp(value ?? list.default.thinkingLevel, levels); const choosable = shown !== undefined && levels.some((level) => level !== "off"); @@ -382,11 +445,6 @@ export function ThinkingPicker({ className="gap-1.5 rounded-md py-1 text-[13px] aria-current:bg-ink/[0.08]" > {levelLabels[level]} - {level === fallback && ( - - Default - - )} ))} diff --git a/apps/web/src/env.ts b/apps/web/src/env.ts index ab743470..df895260 100644 --- a/apps/web/src/env.ts +++ b/apps/web/src/env.ts @@ -8,6 +8,8 @@ declare global { home: string; /** Opens the native folder dialog at `from`; the chosen folder, or null when cancelled. */ pickFolder: (from: string) => Promise; + /** Shows `path` in the file manager, selected. */ + reveal: (path: string) => Promise; }; } } diff --git a/apps/web/src/kernel/rpc.ts b/apps/web/src/kernel/rpc.ts index 28d6c8b8..986afaf4 100644 --- a/apps/web/src/kernel/rpc.ts +++ b/apps/web/src/kernel/rpc.ts @@ -1,4 +1,4 @@ -import { Contract } from "@codeworksh/server/contract"; +import { Contract } from "@codeworksh/contract"; import { Effect, Layer } from "effect"; import { type AsyncResult, type Atom, AtomRegistry, AtomRpc } from "effect/reactivity"; import { RpcClient, RpcSerialization } from "effect/rpc"; diff --git a/apps/web/src/landing/landing.tsx b/apps/web/src/landing/landing.tsx index bd1851ad..22705f88 100644 --- a/apps/web/src/landing/landing.tsx +++ b/apps/web/src/landing/landing.tsx @@ -10,12 +10,13 @@ import { DropdownMenuLabel, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; +import { ComposerNotice } from "@/components/banner"; import { Composer, ComposerButton } from "@/components/composer"; import { makeHost } from "../kernel/host"; import { sessionAddress } from "../widgets/session/address"; import { Json, type Place as Where, topics } from "../widgets/session/schema"; import { useOpenElsewhere } from "../wm/open"; -import { ModelPicker, ThinkingPicker, useChoice } from "@/components/model"; +import { ModelPicker, ThinkingPicker, useChoice, useModelsNotice } from "@/components/model"; import { TitleBar } from "../shell/titlebar"; import { placeAtom, placeLabel, usePickFolder } from "./place"; import { projectsAtom } from "../widgets/session/projects"; @@ -43,6 +44,7 @@ export function Landing() { const { model, thinkingLevel: thinking } = choice; // A folder's project settings may add models or change the default. const hostDir = place.kind === "folder" ? place.path : undefined; + const notice = useModelsNotice(hostDir); const canSend = place.kind !== "none" && query.trim() !== "" && !pending; @@ -82,6 +84,7 @@ export function Landing() {
+ {notice !== undefined && } candidate.isDefault) : undefined; - const settings = () => void navigate({ to: "/settings/servers" }); const settingsLink = ( ); - // Asks the server, then loads the list again once it is up. - const run = (action: "start" | "restart") => { - if (server === undefined) return; - void act(server.id, action).then((done) => done && refreshProjects()); - }; - if (server === undefined) return ( @@ -42,82 +36,33 @@ export function ServerOffline() { ); - const { cli } = server; - if (cli === null) - return ( - - } onClick={settings}> - Set Up - - - ); - if ("failure" in cli) + const problem = problemOf(server); + if (problem === undefined) return ( - - } onClick={settings}> - Server Settings + + } + onClick={() => refreshProjects()} + > + Try Again ); - switch (server.server._tag) { - case "Stopped": - return ( - - } - disabled={running !== null} - onClick={() => run("start")} - > - {running === "start" ? "Starting…" : "Start Server"} - - {error !== null && } - {settingsLink} - - ); - case "Unreachable": - return ( - - } - disabled={running !== null} - onClick={() => run("restart")} - > - {running === "restart" ? "Restarting…" : "Restart"} - - {error !== null && } - {settingsLink} - - ); - case "Incompatible": - return ( - - } - onClick={settings} - > - Server Settings - - - ); - case "Running": - return ( - - } - onClick={() => refreshProjects()} - > - Try Again - - - ); - } + const Icon = fixIcons[problem.fix]; + return ( + + } + disabled={fix.running} + onClick={() => fix.run(server, problem)} + > + {fix.label(problem)} + + {fix.error !== null && } + {problem.fix !== "settings" && settingsLink} + + ); } function Message({ title, line, children }: { title: string; line: string; children?: ReactNode }) { diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index b20b3252..a9498e39 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -1,13 +1,18 @@ +import { useAtomMount } from "@effect/atom-react"; import { createRootRoute, Outlet, useLocation } from "@tanstack/react-router"; import type { CSSProperties } from "react"; import { SidebarProvider } from "@/components/ui/sidebar"; import { SessionsSidebar } from "../landing/sidebar"; +import { serversAtom } from "../settings/servers/local"; import { Palette } from "../shell/palette"; // The sessions sidebar wraps every page but settings, which has its own; its open state carries over. function Root() { const inSettings = useLocation({ select: (location) => location.pathname.startsWith("/settings") }); + // Checking the servers spawns a login shell and the CLI, so it runs from launch and stays held: + // Settings › Servers and the offline sidebar open on the last status instead of loading. + useAtomMount(serversAtom); return ( )} + Local {status.isDefault && Default} @@ -105,6 +105,40 @@ export function LocalItem({ status }: { status: ServerStatus }) { ); } +const DOTS: Record = { + success: "bg-success", + warning: "bg-warning", + danger: "bg-destructive", + info: "bg-info", + muted: "bg-ink-muted/50", +}; + +/** A server's state in a few words, as its row in the list reads it. */ +function stateOf({ cli, server }: ServerStatus): { tone: Tone; text: string } { + if (cli === null) return { tone: "warning", text: "Not installed" }; + if ("failure" in cli) return { tone: "danger", text: "Won't start" }; + switch (server._tag) { + case "Running": + return { tone: "success", text: `Running · ${new URL(server.url).host}` }; + case "Unreachable": + return { tone: "warning", text: "Not answering" }; + case "Incompatible": + return { tone: "danger", text: "Incompatible version" }; + case "Stopped": + return { tone: "muted", text: "Stopped" }; + } +} + +const StatusLine = ({ status }: { status: ServerStatus }) => { + const { tone, text } = stateOf(status); + return ( + + + {text} + + ); +}; + /** A local server: its name, its state and controls, then how it runs and what it starts with. */ export function LocalDetail({ status }: { status: ServerStatus }) { const update = useAtomSet(updateAtom, { mode: "promise" }); @@ -134,6 +168,7 @@ export function LocalDetail({ status }: { status: ServerStatus }) { Local {status.isDefault && Default} + @@ -247,35 +282,26 @@ export function LocalDetail({ status }: { status: ServerStatus }) { ); } -/** - * Whether the app connects through this server. There is always exactly one default, so the default's - * switch stays on: turning another one on moves the default there. - */ -export function DefaultSwitch({ status }: { status: ServerStatus }) { +/** Moves the default, the server the app connects through, here; the default itself shows none. */ +function MakeDefault({ status, onError }: { status: ServerStatus; onError: (message: string) => void }) { const setDefault = useAtomSet(setDefaultAtom, { mode: "promise" }); const [switching, setSwitching] = useState(false); - const [error, setError] = useState(null); + if (status.isDefault) return null; return ( - { - if (!on) return; + ); } diff --git a/apps/web/src/settings/servers/problem.ts b/apps/web/src/settings/servers/problem.ts new file mode 100644 index 00000000..c8cd64ba --- /dev/null +++ b/apps/web/src/settings/servers/problem.ts @@ -0,0 +1,79 @@ +import type { ServerStatus } from "@codeworksh/contract"; +import { useNavigate } from "@tanstack/react-router"; +import { type LucideIcon, Play, RotateCw, Settings } from "lucide-react"; + +import { useServerAction } from "./local"; + +/** Why the default server can't serve the app, and the one thing that fixes it. */ +export interface Problem { + readonly title: string; + /** A sentence naming the server, for where there is room. */ + readonly line: string; + /** The one fact worth a second line where there is little room. */ + readonly detail?: string; + readonly fix: "start" | "restart" | "settings"; + readonly label: string; +} + +/** The default server's problem from its status; undefined while it runs. */ +export function problemOf(status: ServerStatus): Problem | undefined { + const { cli, server } = status; + if (cli === null) + return { + title: "CodeWork Isn't Installed", + line: "Install it, or point to it, to see your projects and sessions.", + fix: "settings", + label: "Set Up", + }; + if ("failure" in cli) + return { + title: "CodeWork Won't Start", + line: `The codework executable for ${status.name} fails to run.`, + fix: "settings", + label: "Server Settings", + }; + switch (server._tag) { + case "Stopped": + return { + title: "Server Not Running", + line: `${status.name} isn't running, so your projects can't load.`, + fix: "start", + label: "Start Server", + }; + case "Unreachable": + return { + title: "Server Not Answering", + line: `${status.name} is registered but doesn't reply.`, + fix: "restart", + label: "Restart", + }; + case "Incompatible": + return { + title: "Incompatible Server Version", + line: `${status.name} runs codework v${server.version}, which this app can't read.`, + detail: `v${server.version}`, + fix: "settings", + label: "Server Settings", + }; + case "Running": + return undefined; + } +} + +export const fixIcons: Record = { start: Play, restart: RotateCw, settings: Settings }; + +/** + * Runs a problem's fix: starts or restarts the server, or opens its settings. `onFixed` runs once + * the server is up, so the caller can load again what failed. + */ +export function useFix(onFixed: () => void) { + const navigate = useNavigate(); + const { running, error, act } = useServerAction(); + const run = (status: ServerStatus, problem: Problem) => { + if (problem.fix === "settings") return void navigate({ to: "/settings/servers" }); + void act(status.id, problem.fix).then((done) => done && onFixed()); + }; + const label = (problem: Problem) => + running === "start" ? "Starting…" : running === "restart" ? "Restarting…" : problem.label; + return { running: running !== null, error, run, label }; +} diff --git a/apps/web/src/settings/servers/servers.tsx b/apps/web/src/settings/servers/servers.tsx index 9fb362a6..0d8e349e 100644 --- a/apps/web/src/settings/servers/servers.tsx +++ b/apps/web/src/settings/servers/servers.tsx @@ -5,13 +5,18 @@ import { useEffect, useState } from "react"; import { Skeleton } from "@/components/ui/skeleton"; import { AddServer } from "./add"; -import { DefaultSwitch, LocalDetail, LocalItem, serversAtom } from "./local"; +import { LocalDetail, LocalItem, serversAtom } from "./local"; /** The CodeWork servers the app can work with, listed beside the one selected; the built-in one is always there. */ export function ServersSettings() { const result = useAtomValue(serversAtom); const refresh = useAtomRefresh(serversAtom); const servers = AsyncResult.isSuccess(result) ? result.value : undefined; + // The status is held from launch (see the root route), so opening the page shows it at once and + // checks again behind it. + useEffect(() => { + if (!result.waiting) refresh(); + }, []); const [selectedId, setSelectedId] = useState(null); const [adding, setAdding] = useState(false); // The default until one is picked, and again when the picked one is gone. @@ -44,22 +49,15 @@ export function ServersSettings() { className="flex flex-col border-edge @max-3xl:border-b @3xl:overflow-y-auto @3xl:border-r" > {servers?.map((server) => ( -
setSelectedId(server.id)} > - - - - -
+ + ))}