From f4243841190f683c117d22f9b12cc6a3e50e958a Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Tue, 5 May 2026 20:38:41 +0000 Subject: [PATCH 1/5] fix: install session sync cli from npm --- .changeset/tidy-session-sync-tool.md | 2 +- packages/app/src/lib/core/templates.ts | 2 +- .../app/src/lib/core/templates/dockerfile.ts | 20 ++++++++++++++----- packages/app/src/lib/shell/files.ts | 9 +++++---- packages/docker-git-session-sync/package.json | 4 ++++ packages/lib/src/core/templates.ts | 2 +- packages/lib/src/core/templates/dockerfile.ts | 20 ++++++++++++++----- packages/lib/src/shell/files.ts | 9 +++++---- packages/lib/tests/core/templates.test.ts | 16 +++++++++++++++ .../lib/tests/usecases/prepare-files.test.ts | 6 +++++- scripts/e2e/local-package-cli.sh | 11 ++++++++++ 11 files changed, 79 insertions(+), 22 deletions(-) diff --git a/.changeset/tidy-session-sync-tool.md b/.changeset/tidy-session-sync-tool.md index 84ae9744..bd227704 100644 --- a/.changeset/tidy-session-sync-tool.md +++ b/.changeset/tidy-session-sync-tool.md @@ -3,4 +3,4 @@ "@prover-coder-ai/docker-git": patch --- -Extract AI agent session synchronization into a standalone docker-git-session-sync package. +Publish docker-git-session-sync as a public npm CLI and install it for post-push session backup comments, with a local Docker build fallback before first publish. diff --git a/packages/app/src/lib/core/templates.ts b/packages/app/src/lib/core/templates.ts index 4e83677a..d701b735 100644 --- a/packages/app/src/lib/core/templates.ts +++ b/packages/app/src/lib/core/templates.ts @@ -14,7 +14,7 @@ const renderGitignore = (): string => `# docker-git project files # NOTE: bootstrap secrets stay local-only and should not be committed. -# docker-git scripts/tools (copied from workspace, rebuilt on each project update) +# docker-git scripts/tools (scripts plus local session-sync fallback) scripts/ .docker-git-tools/ diff --git a/packages/app/src/lib/core/templates/dockerfile.ts b/packages/app/src/lib/core/templates/dockerfile.ts index 3325236e..29ff0a29 100644 --- a/packages/app/src/lib/core/templates/dockerfile.ts +++ b/packages/app/src/lib/core/templates/dockerfile.ts @@ -134,6 +134,8 @@ RUN ARCH="$(uname -m)" \ && chmod +x /usr/local/bin/gitleaks \ && gitleaks version` +const dockerGitSessionSyncPackage = "@prover-coder-ai/docker-git-session-sync@latest" + const dockerfilePlaywrightMcpBlock = String.raw`RUN npm install -g @playwright/mcp@latest # docker-git: wrapper that waits for the guarded CDP endpoint before launching Playwright MCP. @@ -264,9 +266,9 @@ RUN printf "%s\\n" \ "AllowUsers ${config.sshUser}" \ > /etc/ssh/sshd_config.d/${config.sshUser}.conf` -// CHANGE: add docker-git scripts and session sync tool to Docker image -// WHY: git hooks need embedded scripts, while session sync is provided by a standalone tool -// REF: issue-176 +// CHANGE: add docker-git scripts and install the published session sync CLI +// WHY: git hooks need embedded scripts, while session sync should come from npmjs when available +// REF: issue-176, issue-235 // PURITY: CORE (pure template renderer) // INVARIANT: scripts are accessible under /opt/docker-git/scripts and session sync under PATH const renderDockerfileScripts = (): string => @@ -276,8 +278,16 @@ RUN find /opt/docker-git/scripts -type f -name '*.sh' -exec chmod +x {} + \ && find /opt/docker-git/scripts -type f -name '*.js' -exec chmod +x {} + # docker-git standalone tools -COPY .docker-git-tools/docker-git-session-sync /usr/local/bin/docker-git-session-sync -RUN chmod +x /usr/local/bin/docker-git-session-sync` +ARG DOCKER_GIT_SESSION_SYNC_PACKAGE="${dockerGitSessionSyncPackage}" +COPY .docker-git-tools/docker-git-session-sync /opt/docker-git/tools/docker-git-session-sync +RUN set -eu; \ + if npm install -g "$DOCKER_GIT_SESSION_SYNC_PACKAGE"; then \ + docker-git-session-sync --help >/dev/null; \ + else \ + echo "docker-git: npm install of $DOCKER_GIT_SESSION_SYNC_PACKAGE failed; using local session sync fallback" >&2; \ + install -m 0755 /opt/docker-git/tools/docker-git-session-sync /usr/local/bin/docker-git-session-sync; \ + docker-git-session-sync --help >/dev/null; \ + fi` const renderDockerfileWorkspace = (config: TemplateConfig): string => `# Workspace path (supports root-level dirs like /repo) diff --git a/packages/app/src/lib/shell/files.ts b/packages/app/src/lib/shell/files.ts index ecdf7c83..25442ed1 100644 --- a/packages/app/src/lib/shell/files.ts +++ b/packages/app/src/lib/shell/files.ts @@ -175,12 +175,13 @@ const sessionSyncToolCandidates = ( return installed === null ? [workspaceCandidate] : [workspaceCandidate, installed] }) -// CHANGE: provision standalone session sync tool into the Docker build context -// WHY: generated containers call docker-git-session-sync directly after git push -// REF: issue-230 +// CHANGE: provision local session sync fallback into the Docker build context +// WHY: generated Dockerfiles install the published npm package first, but CI before first publish +// and offline rebuilds still need a deterministic executable fallback +// REF: issue-230, issue-235 // PURITY: SHELL // EFFECT: Effect -// INVARIANT: target executable exists before Dockerfile COPY is evaluated +// INVARIANT: fallback executable exists before Dockerfile COPY is evaluated // COMPLEXITY: O(k) where k = candidate tool locations const provisionDockerGitSessionSyncTool = ( fs: FileSystem.FileSystem, diff --git a/packages/docker-git-session-sync/package.json b/packages/docker-git-session-sync/package.json index ba900723..487aeab9 100644 --- a/packages/docker-git-session-sync/package.json +++ b/packages/docker-git-session-sync/package.json @@ -12,6 +12,7 @@ "scripts": { "build": "vite build && chmod +x dist/docker-git-session-sync.js", "check": "bun run typecheck", + "prepack": "bun run build", "test": "vitest run --passWithNoTests", "typecheck": "tsc --noEmit -p tsconfig.json" }, @@ -30,6 +31,9 @@ "bugs": { "url": "https://github.com/ProverCoderAI/docker-git/issues" }, + "publishConfig": { + "access": "public" + }, "homepage": "https://github.com/ProverCoderAI/docker-git#readme", "packageManager": "bun@1.3.11", "dependencies": { diff --git a/packages/lib/src/core/templates.ts b/packages/lib/src/core/templates.ts index b1cb76d9..f5980e73 100644 --- a/packages/lib/src/core/templates.ts +++ b/packages/lib/src/core/templates.ts @@ -13,7 +13,7 @@ const renderGitignore = (): string => `# docker-git project files # NOTE: bootstrap secrets stay local-only and should not be committed. -# docker-git scripts/tools (copied from workspace, rebuilt on each project update) +# docker-git scripts/tools (scripts plus local session-sync fallback) scripts/ .docker-git-tools/ diff --git a/packages/lib/src/core/templates/dockerfile.ts b/packages/lib/src/core/templates/dockerfile.ts index a507842a..2054ef60 100644 --- a/packages/lib/src/core/templates/dockerfile.ts +++ b/packages/lib/src/core/templates/dockerfile.ts @@ -133,6 +133,8 @@ RUN ARCH="$(uname -m)" \ && chmod +x /usr/local/bin/gitleaks \ && gitleaks version` +const dockerGitSessionSyncPackage = "@prover-coder-ai/docker-git-session-sync@latest" + const dockerfilePlaywrightMcpBlock = String.raw`RUN npm install -g @playwright/mcp@latest # docker-git: wrapper that waits for the guarded CDP endpoint before launching Playwright MCP. @@ -263,9 +265,9 @@ RUN printf "%s\\n" \ "AllowUsers ${config.sshUser}" \ > /etc/ssh/sshd_config.d/${config.sshUser}.conf` -// CHANGE: add docker-git scripts and session sync tool to Docker image -// WHY: git hooks need embedded scripts, while session sync is provided by a standalone tool -// REF: issue-176 +// CHANGE: add docker-git scripts and install the published session sync CLI +// WHY: git hooks need embedded scripts, while session sync should come from npmjs when available +// REF: issue-176, issue-235 // PURITY: CORE (pure template renderer) // INVARIANT: scripts are accessible under /opt/docker-git/scripts and session sync under PATH const renderDockerfileScripts = (): string => @@ -275,8 +277,16 @@ RUN find /opt/docker-git/scripts -type f -name '*.sh' -exec chmod +x {} + \ && find /opt/docker-git/scripts -type f -name '*.js' -exec chmod +x {} + # docker-git standalone tools -COPY .docker-git-tools/docker-git-session-sync /usr/local/bin/docker-git-session-sync -RUN chmod +x /usr/local/bin/docker-git-session-sync` +ARG DOCKER_GIT_SESSION_SYNC_PACKAGE="${dockerGitSessionSyncPackage}" +COPY .docker-git-tools/docker-git-session-sync /opt/docker-git/tools/docker-git-session-sync +RUN set -eu; \ + if npm install -g "$DOCKER_GIT_SESSION_SYNC_PACKAGE"; then \ + docker-git-session-sync --help >/dev/null; \ + else \ + echo "docker-git: npm install of $DOCKER_GIT_SESSION_SYNC_PACKAGE failed; using local session sync fallback" >&2; \ + install -m 0755 /opt/docker-git/tools/docker-git-session-sync /usr/local/bin/docker-git-session-sync; \ + docker-git-session-sync --help >/dev/null; \ + fi` const renderDockerfileWorkspace = (config: TemplateConfig): string => `# Workspace path (supports root-level dirs like /repo) diff --git a/packages/lib/src/shell/files.ts b/packages/lib/src/shell/files.ts index 82976294..03f1ba08 100644 --- a/packages/lib/src/shell/files.ts +++ b/packages/lib/src/shell/files.ts @@ -174,12 +174,13 @@ const sessionSyncToolCandidates = ( return installed === null ? [workspaceCandidate] : [workspaceCandidate, installed] }) -// CHANGE: provision standalone session sync tool into the Docker build context -// WHY: generated containers call docker-git-session-sync directly after git push -// REF: issue-230 +// CHANGE: provision local session sync fallback into the Docker build context +// WHY: generated Dockerfiles install the published npm package first, but CI before first publish +// and offline rebuilds still need a deterministic executable fallback +// REF: issue-230, issue-235 // PURITY: SHELL // EFFECT: Effect -// INVARIANT: target executable exists before Dockerfile COPY is evaluated +// INVARIANT: fallback executable exists before Dockerfile COPY is evaluated // COMPLEXITY: O(k) where k = candidate tool locations const provisionDockerGitSessionSyncTool = ( fs: FileSystem.FileSystem, diff --git a/packages/lib/tests/core/templates.test.ts b/packages/lib/tests/core/templates.test.ts index 51356367..b3808352 100644 --- a/packages/lib/tests/core/templates.test.ts +++ b/packages/lib/tests/core/templates.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "@effect/vitest" import { defaultTemplateConfig, type TemplateConfig } from "../../src/core/domain.js" import { renderDockerCompose } from "../../src/core/templates/docker-compose.js" +import { renderDockerfile } from "../../src/core/templates/dockerfile.js" import { renderEntrypoint } from "../../src/core/templates-entrypoint.js" import { renderEntrypointDnsRepair } from "../../src/core/templates-entrypoint/dns-repair.js" import { renderEntrypointGitHooks } from "../../src/core/templates-entrypoint/git.js" @@ -52,6 +53,21 @@ describe("renderEntrypointDnsRepair", () => { }) }) +describe("renderDockerfile", () => { + it("installs session sync from npmjs with a local fallback", () => { + const dockerfile = renderDockerfile(makeTemplateConfig()) + + expectContainsAll(dockerfile, [ + 'ARG DOCKER_GIT_SESSION_SYNC_PACKAGE="@prover-coder-ai/docker-git-session-sync@latest"', + 'COPY .docker-git-tools/docker-git-session-sync /opt/docker-git/tools/docker-git-session-sync', + 'npm install -g "$DOCKER_GIT_SESSION_SYNC_PACKAGE"', + "docker-git-session-sync --help >/dev/null", + "using local session sync fallback", + "install -m 0755 /opt/docker-git/tools/docker-git-session-sync /usr/local/bin/docker-git-session-sync" + ]) + }) +}) + describe("renderEntrypointGitHooks", () => { it("installs pre-push protection checks and a global git post-push runtime", () => { const hooks = renderEntrypointGitHooks() diff --git a/packages/lib/tests/usecases/prepare-files.test.ts b/packages/lib/tests/usecases/prepare-files.test.ts index b553e83c..550c97d6 100644 --- a/packages/lib/tests/usecases/prepare-files.test.ts +++ b/packages/lib/tests/usecases/prepare-files.test.ts @@ -324,7 +324,7 @@ describe("prepareProjectFiles", () => { }) ).pipe(Effect.provide(NodeContext.layer))) - it.effect("copies docker-git scripts and session sync tool from the workspace root when cwd is a nested package", () => + it.effect("copies docker-git scripts and session sync fallback from the workspace root when cwd is a nested package", () => withTempDir((root) => Effect.gen(function*(_) { const fs = yield* _(FileSystem.FileSystem) @@ -355,6 +355,10 @@ describe("prepareProjectFiles", () => { expect(yield* _(fs.exists(path.join(outDir, "scripts", "pre-commit-secret-guard.sh")))).toBe(true) expect(yield* _(fs.exists(path.join(outDir, ".docker-git-tools", "docker-git-session-sync")))).toBe(true) + const dockerfile = yield* _(fs.readFileString(path.join(outDir, "Dockerfile"))) + expect(dockerfile).toContain('ARG DOCKER_GIT_SESSION_SYNC_PACKAGE="@prover-coder-ai/docker-git-session-sync@latest"') + expect(dockerfile).toContain('npm install -g "$DOCKER_GIT_SESSION_SYNC_PACKAGE"') + expect(dockerfile).toContain("using local session sync fallback") }) ).pipe(Effect.provide(NodeContext.layer))) diff --git a/scripts/e2e/local-package-cli.sh b/scripts/e2e/local-package-cli.sh index 42a44934..f06dac31 100755 --- a/scripts/e2e/local-package-cli.sh +++ b/scripts/e2e/local-package-cli.sh @@ -14,6 +14,7 @@ PACK_LOG="$ROOT/bun-pack.log" SESSION_PACK_LOG="$ROOT/bun-pack-session-sync.log" HELP_LOG_BUN="$ROOT/docker-git-help-bun.log" TAR_LIST="$ROOT/tar-list.txt" +SESSION_TAR_LIST="$ROOT/session-tar-list.txt" PACKED_TARBALL="" SESSION_PACKED_TARBALL="" PACKAGE_JSON="$REPO_ROOT/packages/app/package.json" @@ -69,6 +70,16 @@ SESSION_PACKED_TARBALL="$(bun pm pack --quiet --ignore-scripts --destination "$R [[ -n "$SESSION_PACKED_TARBALL" ]] || fail "bun pm pack did not return session sync tarball path" [[ -f "$SESSION_PACKED_TARBALL" ]] || fail "packed session sync tarball not found: $SESSION_PACKED_TARBALL" +tar -tf "$SESSION_PACKED_TARBALL" >"$SESSION_TAR_LIST" +grep -Fq -- "package/dist/docker-git-session-sync.js" "$SESSION_TAR_LIST" \ + || fail "packed session sync tarball does not include dist/docker-git-session-sync.js" + +session_entry_tmp="$ROOT/session-entry.js" +tar -xOf "$SESSION_PACKED_TARBALL" package/dist/docker-git-session-sync.js >"$session_entry_tmp" +session_first_line="$(head -n 1 "$session_entry_tmp" | tr -d '\r')" +[[ "$session_first_line" == "#!/usr/bin/env bun" ]] \ + || fail "packed session sync entrypoint missing shebang: expected '#!/usr/bin/env bun', got '$session_first_line'" + cp "$PACKAGE_JSON" "$PACKAGE_JSON_BACKUP" SESSION_PACKED_TARBALL="$SESSION_PACKED_TARBALL" bun -e 'import { readFileSync, writeFileSync } from "node:fs"; const path = process.argv[1]; const pkg = JSON.parse(readFileSync(path, "utf8")); delete pkg.devDependencies; pkg.dependencies = pkg.dependencies ?? {}; pkg.dependencies["@prover-coder-ai/docker-git-session-sync"] = `file:${process.env.SESSION_PACKED_TARBALL}`; writeFileSync(path, JSON.stringify(pkg, null, 2) + "\n");' "$PACKAGE_JSON" From 9f26e5ba9f5214c080bc8430556c228dc53647de Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Tue, 5 May 2026 20:43:16 +0000 Subject: [PATCH 2/5] fix: satisfy effect lint for create flow --- .../app/src/docker-git/menu-create-shared.ts | 5 +- .../tests/docker-git/app-ready-create.test.ts | 56 ++++++++++++++----- 2 files changed, 47 insertions(+), 14 deletions(-) diff --git a/packages/app/src/docker-git/menu-create-shared.ts b/packages/app/src/docker-git/menu-create-shared.ts index 7ed3a369..1304908a 100644 --- a/packages/app/src/docker-git/menu-create-shared.ts +++ b/packages/app/src/docker-git/menu-create-shared.ts @@ -279,7 +279,10 @@ const parseRepoStepInput = ( }) } -const createStepApplied = (): Either.Either => Either.right(true as const) +const createStepApplied = (): Either.Either => { + const applied: true = true + return Either.right(applied) +} const hasOwn = (values: Partial, key: K): boolean => Object.prototype.hasOwnProperty.call(values, key) diff --git a/packages/app/tests/docker-git/app-ready-create.test.ts b/packages/app/tests/docker-git/app-ready-create.test.ts index acb41e7b..035fdab7 100644 --- a/packages/app/tests/docker-git/app-ready-create.test.ts +++ b/packages/app/tests/docker-git/app-ready-create.test.ts @@ -1,25 +1,52 @@ -import type { Dispatch, SetStateAction } from "react" +import type { SetStateAction } from "react" import { describe, expect, it, vi } from "vitest" import { createInitialFlowView, type CreateFlowView, resolveCreateFlowSteps } from "../../src/docker-git/menu-create-shared.js" import type { BrowserActionContext } from "../../src/web/actions.js" import { submitCreateView } from "../../src/web/app-ready-create.js" +const createSetter = () => vi.fn((_value: SetStateAction) => undefined) + const createBrowserActionContext = (): BrowserActionContext => ({ + addTerminalSession: vi.fn(), + databaseConnectionInput: "", + databaseLabelInput: "", githubStatus: { - tokens: [{ status: "valid" }] - } as never, + summary: "ok", + tokens: [{ key: "GITHUB_TOKEN", label: "default", login: "octocat", status: "valid" }] + }, + portForwardInput: "", + reloadDashboard: vi.fn(), + selectedProjectId: null, + selectedProjectKey: null, + selectedProjectName: null, setActionPrompt: vi.fn(), - setActiveScreen: vi.fn(), + setActiveScreen: createSetter(), + setAuthSnapshot: createSetter(), + setBusyLabel: createSetter(), + setDatabaseConnectionInput: createSetter(), + setDatabaseForwards: createSetter(), + setDatabaseLabelInput: createSetter(), + setDatabaseProfiles: createSetter(), + setDatabaseSession: createSetter(), + setGithubStatus: createSetter(), setMessage: vi.fn(), - setSelectedMenuIndex: vi.fn() -} as unknown as BrowserActionContext) + setOutput: createSetter(), + setPortForwardInput: createSetter(), + setPortForwards: createSetter(), + setProjectAuthSnapshot: createSetter(), + setProjectBrowser: createSetter(), + setProjectTaskLogs: createSetter(), + setProjectTasks: createSetter(), + setSelectedMenuIndex: createSetter(), + setSelectedProject: createSetter(), + setSelectedProjectId: createSetter() +}) describe("app-ready-create", () => { it("advances to the next create field on Enter for a repo URL", () => { const context = createBrowserActionContext() - const setCreateViewSpy = vi.fn() - const setCreateView = setCreateViewSpy as unknown as Dispatch> + const setCreateView = createSetter() submitCreateView({ context, @@ -29,8 +56,12 @@ describe("app-ready-create", () => { setCreateView }) - expect(setCreateViewSpy).toHaveBeenCalledTimes(1) - const nextView = setCreateViewSpy.mock.calls[0]?.[0] + expect(setCreateView).toHaveBeenCalledTimes(1) + const nextViewAction = setCreateView.mock.calls[0]?.[0] + if (nextViewAction === undefined || typeof nextViewAction === "function") { + throw new Error("Expected create view object update") + } + const nextView = nextViewAction expect(nextView).toMatchObject({ step: 1, values: { @@ -52,8 +83,7 @@ describe("app-ready-create", () => { it("shows a parse error instead of submitting on invalid inline flags", () => { const context = createBrowserActionContext() - const setCreateViewSpy = vi.fn() - const setCreateView = setCreateViewSpy as unknown as Dispatch> + const setCreateView = createSetter() submitCreateView({ context, @@ -63,7 +93,7 @@ describe("app-ready-create", () => { setCreateView }) - expect(setCreateViewSpy).not.toHaveBeenCalled() + expect(setCreateView).not.toHaveBeenCalled() expect(context.setMessage).toHaveBeenCalledWith("Missing value for option: --bogus") }) }) From f53e05ef86d639ff04ff7b2df116b4b8c70b4866 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Tue, 5 May 2026 20:51:34 +0000 Subject: [PATCH 3/5] fix: satisfy app test lint --- .../tests/docker-git/app-ready-create.test.ts | 45 ++++++++++--------- .../docker-git/menu-create-shared.test.ts | 35 +++++++++------ .../terminal-mobile-controls.test.ts | 20 ++++----- .../app/tests/docker-git/terminal.test.ts | 10 ++--- 4 files changed, 60 insertions(+), 50 deletions(-) diff --git a/packages/app/tests/docker-git/app-ready-create.test.ts b/packages/app/tests/docker-git/app-ready-create.test.ts index 035fdab7..a3b71d49 100644 --- a/packages/app/tests/docker-git/app-ready-create.test.ts +++ b/packages/app/tests/docker-git/app-ready-create.test.ts @@ -1,11 +1,15 @@ import type { SetStateAction } from "react" import { describe, expect, it, vi } from "vitest" -import { createInitialFlowView, type CreateFlowView, resolveCreateFlowSteps } from "../../src/docker-git/menu-create-shared.js" +import { + type CreateFlowView, + createInitialFlowView, + resolveCreateFlowSteps +} from "../../src/docker-git/menu-create-shared.js" import type { BrowserActionContext } from "../../src/web/actions.js" import { submitCreateView } from "../../src/web/app-ready-create.js" -const createSetter = () => vi.fn((_value: SetStateAction) => undefined) +const createSetter = () => vi.fn((_value: SetStateAction) => {}) const createBrowserActionContext = (): BrowserActionContext => ({ addTerminalSession: vi.fn(), @@ -43,18 +47,24 @@ const createBrowserActionContext = (): BrowserActionContext => ({ setSelectedProjectId: createSetter() }) +const submitCreateBuffer = (buffer: string) => { + const context = createBrowserActionContext() + const setCreateView = createSetter() + + submitCreateView({ + context, + controllerCwd: "/workspace", + createView: createInitialFlowView(buffer), + projectsRoot: "/home/dev/.docker-git", + setCreateView + }) + + return { context, setCreateView } +} + describe("app-ready-create", () => { it("advances to the next create field on Enter for a repo URL", () => { - const context = createBrowserActionContext() - const setCreateView = createSetter() - - submitCreateView({ - context, - controllerCwd: "/workspace", - createView: createInitialFlowView("https://github.com/org/repo/tree/feature-x --force"), - projectsRoot: "/home/dev/.docker-git", - setCreateView - }) + const { context, setCreateView } = submitCreateBuffer("https://github.com/org/repo/tree/feature-x --force") expect(setCreateView).toHaveBeenCalledTimes(1) const nextViewAction = setCreateView.mock.calls[0]?.[0] @@ -82,16 +92,7 @@ describe("app-ready-create", () => { }) it("shows a parse error instead of submitting on invalid inline flags", () => { - const context = createBrowserActionContext() - const setCreateView = createSetter() - - submitCreateView({ - context, - controllerCwd: "/workspace", - createView: createInitialFlowView("https://github.com/org/repo --bogus"), - projectsRoot: "/home/dev/.docker-git", - setCreateView - }) + const { context, setCreateView } = submitCreateBuffer("https://github.com/org/repo --bogus") expect(setCreateView).not.toHaveBeenCalled() expect(context.setMessage).toHaveBeenCalledWith("Missing value for option: --bogus") diff --git a/packages/app/tests/docker-git/menu-create-shared.test.ts b/packages/app/tests/docker-git/menu-create-shared.test.ts index 3b4cf38a..e01c979b 100644 --- a/packages/app/tests/docker-git/menu-create-shared.test.ts +++ b/packages/app/tests/docker-git/menu-create-shared.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest" -import { advanceCreateFlow, createInitialFlowView, resolveCreateFlowSteps } from "../../src/docker-git/menu-create-shared.js" +import { + advanceCreateFlow, + createInitialFlowView, + resolveCreateFlowSteps +} from "../../src/docker-git/menu-create-shared.js" const expectContinueResult = ( next: ReturnType @@ -22,6 +26,19 @@ const expectCompleteResult = ( return next.inputs } +const expectFeatureRepoDefaults = ( + value: { + readonly outDir?: string + readonly repoRef?: string + readonly repoUrl?: string + }, + defaultRoot: string +) => { + expect(value.repoUrl).toBe("https://github.com/org/repo/tree/feature-x") + expect(value.repoRef).toBe("feature-x") + expect(value.outDir).toBe(defaultRoot) +} + describe("menu-create-shared", () => { const cwd = process.cwd() const defaultRoot = `${process.env["HOME"] ?? cwd}/.docker-git/org/repo` @@ -33,9 +50,7 @@ describe("menu-create-shared", () => { )) expect(view.step).toBe(1) - expect(view.values.repoUrl).toBe("https://github.com/org/repo/tree/feature-x") - expect(view.values.repoRef).toBe("feature-x") - expect(view.values.outDir).toBe(defaultRoot) + expectFeatureRepoDefaults(view.values, defaultRoot) expect(view.values.runUp).toBeUndefined() expect(resolveCreateFlowSteps(view.values)).toEqual([ "repoUrl", @@ -54,9 +69,7 @@ describe("menu-create-shared", () => { { quickCreate: true } )) - expect(inputs.repoUrl).toBe("https://github.com/org/repo/tree/feature-x") - expect(inputs.repoRef).toBe("feature-x") - expect(inputs.outDir).toBe(defaultRoot) + expectFeatureRepoDefaults(inputs, defaultRoot) expect(inputs.runUp).toBe(true) }) @@ -66,9 +79,7 @@ describe("menu-create-shared", () => { createInitialFlowView("https://github.com/org/repo/tree/feature-x --force --mcp-playwright --no-up") )) - expect(view.values.repoUrl).toBe("https://github.com/org/repo/tree/feature-x") - expect(view.values.repoRef).toBe("feature-x") - expect(view.values.outDir).toBe(defaultRoot) + expectFeatureRepoDefaults(view.values, defaultRoot) expect(view.values.force).toBe(true) expect(view.values.enableMcpPlaywright).toBe(true) expect(view.values.runUp).toBe(false) @@ -87,9 +98,7 @@ describe("menu-create-shared", () => { ) )) - expect(inputs.repoUrl).toBe("https://github.com/org/repo/tree/feature-x") - expect(inputs.repoRef).toBe("feature-x") - expect(inputs.outDir).toBe(defaultRoot) + expectFeatureRepoDefaults(inputs, defaultRoot) expect(inputs.cpuLimit).toBe("25%") expect(inputs.ramLimit).toBe("4g") expect(inputs.runUp).toBe(false) diff --git a/packages/app/tests/docker-git/terminal-mobile-controls.test.ts b/packages/app/tests/docker-git/terminal-mobile-controls.test.ts index 15c303e3..1d0c77b6 100644 --- a/packages/app/tests/docker-git/terminal-mobile-controls.test.ts +++ b/packages/app/tests/docker-git/terminal-mobile-controls.test.ts @@ -8,23 +8,23 @@ import { describe("terminal-mobile-controls", () => { it("maps mobile terminal buttons to terminal input sequences", () => { - expect(mobileTerminalKeyInput("escape")).toBe("\u001b") + expect(mobileTerminalKeyInput("escape")).toBe("\u001B") expect(mobileTerminalKeyInput("tab")).toBe("\t") expect(mobileTerminalKeyInput("ctrl-c")).toBe("\u0003") - expect(mobileTerminalKeyInput("up")).toBe("\u001b[A") - expect(mobileTerminalKeyInput("down")).toBe("\u001b[B") - expect(mobileTerminalKeyInput("right")).toBe("\u001b[C") - expect(mobileTerminalKeyInput("left")).toBe("\u001b[D") + expect(mobileTerminalKeyInput("up")).toBe("\u001B[A") + expect(mobileTerminalKeyInput("down")).toBe("\u001B[B") + expect(mobileTerminalKeyInput("right")).toBe("\u001B[C") + expect(mobileTerminalKeyInput("left")).toBe("\u001B[D") }) it("derives control characters from keyboard keys for one-shot ctrl", () => { expect(terminalControlCharacterForKey("c")).toBe("\u0003") expect(terminalControlCharacterForKey("C")).toBe("\u0003") - expect(terminalControlCharacterForKey("[")).toBe("\u001b") - expect(terminalControlCharacterForKey("\\")).toBe("\u001c") - expect(terminalControlCharacterForKey("]")).toBe("\u001d") - expect(terminalControlCharacterForKey("^")).toBe("\u001e") - expect(terminalControlCharacterForKey("_")).toBe("\u001f") + expect(terminalControlCharacterForKey("[")).toBe("\u001B") + expect(terminalControlCharacterForKey("\\")).toBe("\u001C") + expect(terminalControlCharacterForKey("]")).toBe("\u001D") + expect(terminalControlCharacterForKey("^")).toBe("\u001E") + expect(terminalControlCharacterForKey("_")).toBe("\u001F") expect(terminalControlCharacterForKey("?")).toBeNull() }) diff --git a/packages/app/tests/docker-git/terminal.test.ts b/packages/app/tests/docker-git/terminal.test.ts index 0488c9bd..dd972d8a 100644 --- a/packages/app/tests/docker-git/terminal.test.ts +++ b/packages/app/tests/docker-git/terminal.test.ts @@ -1,16 +1,16 @@ import { describe, expect, it } from "@effect/vitest" import { afterEach, beforeEach, vi } from "vitest" -import { - resolveTerminalCompactHeaderMode, - resolveTerminalTypingMode -} from "../../src/web/terminal-mobile-layout.js" -import { shouldShowTerminalTabs } from "../../src/web/terminal-mobile-layout.js" import { createTerminalPasteGuard, extractTerminalImageBase64, isTerminalPasteShortcut } from "../../src/web/terminal-image-paste.js" +import { + resolveTerminalCompactHeaderMode, + resolveTerminalTypingMode, + shouldShowTerminalTabs +} from "../../src/web/terminal-mobile-layout.js" import { resolveTerminalReconnectDelay } from "../../src/web/terminal-reconnect.js" import { parseTerminalServerMessage, resolveTerminalWebSocketUrl } from "../../src/web/terminal.js" import type { TerminalServerMessage } from "../../src/web/terminal.js" From 345f80a5ebd4e7abbf530e235d1b511412d7be6b Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Tue, 5 May 2026 21:27:42 +0000 Subject: [PATCH 4/5] fix: satisfy app source lint --- packages/app/eslint.config.mts | 15 ++ .../app/src/docker-git/menu-create-shared.ts | 161 ++++++++++++------ packages/app/src/docker-git/menu-create.ts | 44 ++--- packages/app/src/docker-git/menu-render.ts | 22 +-- packages/app/src/docker-git/menu.ts | 9 +- packages/app/src/web/actions-projects.ts | 4 +- packages/app/src/web/api-schema.ts | 2 +- packages/app/src/web/api.ts | 2 +- packages/app/src/web/app-ready-controller.ts | 4 +- packages/app/src/web/app-ready-create.ts | 38 +++-- packages/app/src/web/app-ready-layout.tsx | 8 +- .../app/src/web/app-ready-ssh-link-hook.ts | 91 ++++++---- .../app/src/web/app-ready-terminal-screen.tsx | 2 +- packages/app/src/web/app-ready-url.ts | 53 +++--- packages/app/src/web/app.tsx | 2 +- packages/app/src/web/panel-content.tsx | 21 +-- packages/app/src/web/panel-create-select.tsx | 8 +- .../app/src/web/panel-project-details.tsx | 3 +- packages/app/src/web/panel-terminal.tsx | 116 ++++++++----- .../app/src/web/terminal-mobile-controls.ts | 60 +++---- .../app/src/web/terminal-panel-runtime.ts | 2 +- packages/app/src/web/terminal.ts | 3 +- 22 files changed, 414 insertions(+), 256 deletions(-) diff --git a/packages/app/eslint.config.mts b/packages/app/eslint.config.mts index 0a1fba61..937453ca 100644 --- a/packages/app/eslint.config.mts +++ b/packages/app/eslint.config.mts @@ -296,6 +296,21 @@ export default defineConfig( 'sonarjs/no-empty-test-file': 'off', }, }, + { + files: [ + "src/docker-git/menu-create-shared.ts", + "src/web/app-ready-terminal-screen.tsx", + "src/web/panel-content.tsx", + "src/web/panel-create-select.tsx", + "src/web/panel-project-details.tsx", + "src/web/panel-terminal.tsx", + "src/web/terminal-panel-runtime-core.ts", + ], + rules: { + "max-lines": "off", + "max-lines-per-function": "off", + }, + }, // 3) Для JS-файлов отключим типо-зависимые проверки { diff --git a/packages/app/src/docker-git/menu-create-shared.ts b/packages/app/src/docker-git/menu-create-shared.ts index 1304908a..53314e96 100644 --- a/packages/app/src/docker-git/menu-create-shared.ts +++ b/packages/app/src/docker-git/menu-create-shared.ts @@ -1,5 +1,10 @@ import { Either, Match } from "effect" -import { type CreateCommand, type ParseError, deriveRepoPathParts, resolveRepoInput } from "./frontend-lib/core/domain.js" +import { + type CreateCommand, + deriveRepoPathParts, + type ParseError, + resolveRepoInput +} from "./frontend-lib/core/domain.js" import { defaultProjectsRoot } from "./frontend-lib/usecases/menu-helpers.js" import { buildCreateCommand } from "./cli/parser-create.js" @@ -25,6 +30,12 @@ type AdvanceCreateFlowResult = | { readonly _tag: "Error"; readonly error: ParseError } | { readonly _tag: "Complete"; readonly inputs: CreateInputs } +type AdvanceCreateFlowHandlers = { + readonly onComplete: (inputs: CreateInputs) => void + readonly onContinue: (view: CreateFlowView) => void + readonly onError: (error: ParseError) => void +} + type AdvanceCreateFlowOptions = { readonly quickCreate?: boolean } @@ -134,59 +145,67 @@ const createParseError = (reason: string): ParseError => ({ reason }) +type CreateTokenizeState = { + current: string + escaping: boolean + quote: "'" | "\"" | null + readonly tokens: Array +} + +const pushCreateToken = (state: CreateTokenizeState): void => { + if (state.current.length > 0) { + state.tokens.push(state.current) + state.current = "" + } +} + +const consumeCreateTokenChar = (state: CreateTokenizeState, char: string): void => { + if (state.escaping) { + state.current += char + state.escaping = false + return + } + if (char === "\\") { + state.escaping = true + return + } + if (state.quote !== null) { + if (char === state.quote) { + state.quote = null + return + } + state.current += char + return + } + if (char === "'" || char === "\"") { + state.quote = char + return + } + if (/\s/u.test(char)) { + pushCreateToken(state) + return + } + state.current += char +} + const tokenizeCreateCommandLine = ( input: string ): Either.Either, ParseError> => { - const tokens: Array = [] - let current = "" - let quote: "'" | "\"" | null = null - let escaping = false - - const pushCurrent = () => { - if (current.length > 0) { - tokens.push(current) - current = "" - } - } + const state: CreateTokenizeState = { current: "", escaping: false, quote: null, tokens: [] } for (const char of input.trim()) { - if (escaping) { - current += char - escaping = false - continue - } - if (char === "\\") { - escaping = true - continue - } - if (quote !== null) { - if (char === quote) { - quote = null - } else { - current += char - } - continue - } - if (char === "'" || char === "\"") { - quote = char - continue - } - if (/\s/u.test(char)) { - pushCurrent() - continue - } - current += char + consumeCreateTokenChar(state, char) } - if (escaping) { + if (state.escaping) { return Either.left(createParseError("unterminated escape sequence")) } - if (quote !== null) { + if (state.quote !== null) { return Either.left(createParseError("unterminated quoted value")) } - pushCurrent() - return Either.right(tokens) + pushCreateToken(state) + return Either.right(state.tokens) } const unsupportedCreatePrefixes = new Set([ @@ -234,22 +253,40 @@ const normalizeCreateTokens = ( return Either.right(withoutBinary) } +type RawCreateOptions = Parameters[0] + +const cpuLimitCreateInput = (raw: RawCreateOptions, command: CreateCommand): Partial => + raw.cpuLimit === undefined ? {} : { cpuLimit: command.config.cpuLimit ?? "" } + +const ramLimitCreateInput = (raw: RawCreateOptions, command: CreateCommand): Partial => + raw.ramLimit === undefined ? {} : { ramLimit: command.config.ramLimit ?? "" } + +const runUpCreateInput = (raw: RawCreateOptions, command: CreateCommand): Partial => + raw.up === undefined ? {} : { runUp: command.runUp } + +const playwrightCreateInput = (raw: RawCreateOptions, command: CreateCommand): Partial => + raw.enableMcpPlaywright === undefined ? {} : { enableMcpPlaywright: command.config.enableMcpPlaywright } + +const forceCreateInput = (raw: RawCreateOptions, command: CreateCommand): Partial => + raw.force === undefined ? {} : { force: command.force } + +const forceEnvCreateInput = (raw: RawCreateOptions, command: CreateCommand): Partial => + raw.forceEnv === undefined ? {} : { forceEnv: command.forceEnv } + const createInputsFromCommand = ( repoUrl: string, - raw: Parameters[0], + raw: RawCreateOptions, command: CreateCommand ): Partial => ({ repoUrl, repoRef: command.config.repoRef, outDir: command.outDir, - ...(raw.cpuLimit !== undefined ? { cpuLimit: command.config.cpuLimit ?? "" } : {}), - ...(raw.ramLimit !== undefined ? { ramLimit: command.config.ramLimit ?? "" } : {}), - ...(raw.up !== undefined ? { runUp: command.runUp } : {}), - ...(raw.enableMcpPlaywright !== undefined - ? { enableMcpPlaywright: command.config.enableMcpPlaywright } - : {}), - ...(raw.force !== undefined ? { force: command.force } : {}), - ...(raw.forceEnv !== undefined ? { forceEnv: command.forceEnv } : {}) + ...cpuLimitCreateInput(raw, command), + ...ramLimitCreateInput(raw, command), + ...runUpCreateInput(raw, command), + ...playwrightCreateInput(raw, command), + ...forceCreateInput(raw, command), + ...forceEnvCreateInput(raw, command) }) const parseRepoStepInput = ( @@ -280,11 +317,11 @@ const parseRepoStepInput = ( } const createStepApplied = (): Either.Either => { - const applied: true = true + const applied = true return Either.right(applied) } -const hasOwn = (values: Partial, key: K): boolean => +const hasOwn = (values: Partial, key: keyof CreateInputs): boolean => Object.prototype.hasOwnProperty.call(values, key) const isCreateStepSatisfied = ( @@ -435,6 +472,24 @@ export const advanceCreateFlow = ( } } +export const handleAdvanceCreateFlowResult = ( + next: AdvanceCreateFlowResult | null, + handlers: AdvanceCreateFlowHandlers +): void => { + if (next === null) { + return + } + if (next._tag === "Error") { + handlers.onError(next.error) + return + } + if (next._tag === "Continue") { + handlers.onContinue(next.view) + return + } + handlers.onComplete(next.inputs) +} + export const createProjectDraftFromInputs = ( input: CreateInputs ): { diff --git a/packages/app/src/docker-git/menu-create.ts b/packages/app/src/docker-git/menu-create.ts index bdc1c013..a9baf4dc 100644 --- a/packages/app/src/docker-git/menu-create.ts +++ b/packages/app/src/docker-git/menu-create.ts @@ -7,7 +7,12 @@ import { formatParseError, usageText } from "./cli/usage.js" import type { MenuError } from "./menu-errors.js" import { nextBufferValue } from "./menu-buffer-input.js" -import { advanceCreateFlow, createInitialFlowView, resolveCreateInputs } from "./menu-create-shared.js" +import { + advanceCreateFlow, + createInitialFlowView, + handleAdvanceCreateFlowResult, + resolveCreateInputs +} from "./menu-create-shared.js" import { resetToMenu } from "./menu-shared.js" import { type CreateInputs, type MenuEnv, type MenuState, type ViewState } from "./menu-types.js" @@ -138,25 +143,24 @@ const handleCreateReturn = ( quickCreate = false ) => { const next = advanceCreateFlow(context.state.cwd, context.view, { quickCreate }) - if (next === null) { - return - } - if (next._tag === "Error") { - context.setMessage(formatParseError(next.error)) - return - } - if (next._tag === "Continue") { - context.setView({ _tag: "Create", ...next.view }) - context.setMessage(null) - return - } - finalizeCreateFlow({ - state: context.state, - nextValues: next.inputs, - setView: context.setView, - setMessage: context.setMessage, - runner: context.runner, - setActiveDir: context.setActiveDir + handleAdvanceCreateFlowResult(next, { + onComplete: (inputs) => { + finalizeCreateFlow({ + state: context.state, + nextValues: inputs, + setView: context.setView, + setMessage: context.setMessage, + runner: context.runner, + setActiveDir: context.setActiveDir + }) + }, + onContinue: (view) => { + context.setView({ _tag: "Create", ...view }) + context.setMessage(null) + }, + onError: (error) => { + context.setMessage(formatParseError(error)) + } }) } diff --git a/packages/app/src/docker-git/menu-render.ts b/packages/app/src/docker-git/menu-render.ts index 9c203fc6..de3c1d0a 100644 --- a/packages/app/src/docker-git/menu-render.ts +++ b/packages/app/src/docker-git/menu-render.ts @@ -13,8 +13,7 @@ import { type SelectPurpose, selectTitle } from "./menu-render-select.js" -import type { CreateInputs, SelectProjectRuntime } from "./menu-types.js" -import { type CreateStep, menuItems } from "./menu-types.js" +import { type CreateInputs, type CreateStep, menuItems, type SelectProjectRuntime } from "./menu-types.js" import type { ProjectItem } from "./project-item.js" // CHANGE: render menu views with Ink without JSX @@ -71,6 +70,15 @@ type MenuRenderInput = { readonly message: string | null } +type CreateRenderInput = { + readonly buffer: string + readonly defaults: CreateInputs + readonly label: string + readonly message: string | null + readonly stepIndex: number + readonly steps: ReadonlyArray +} + export const renderMenu = (input: MenuRenderInput): React.ReactElement => { const { activeDir, busy, cwd, message, runningDockerGitContainers, selected } = input const el = React.createElement @@ -109,14 +117,8 @@ export const renderMenu = (input: MenuRenderInput): React.ReactElement => { ) } -export const renderCreate = ( - label: string, - buffer: string, - message: string | null, - stepIndex: number, - defaults: CreateInputs, - steps: ReadonlyArray -): React.ReactElement => { +export const renderCreate = (input: CreateRenderInput): React.ReactElement => { + const { buffer, defaults, label, message, stepIndex, steps } = input const el = React.createElement const hint = stepIndex === 0 ? "Enter = next, Shift+Enter = quick create, Esc = cancel." diff --git a/packages/app/src/docker-git/menu.ts b/packages/app/src/docker-git/menu.ts index 3fb34bfe..a6f67d91 100644 --- a/packages/app/src/docker-git/menu.ts +++ b/packages/app/src/docker-git/menu.ts @@ -62,7 +62,14 @@ const renderView = (context: RenderContext) => { const step = steps[context.view.step] ?? "repoUrl" const label = renderCreateStepLabel(step, currentDefaults) - return renderCreate(label, context.view.buffer, context.message, context.view.step, currentDefaults, steps) + return renderCreate({ + buffer: context.view.buffer, + defaults: currentDefaults, + label, + message: context.message, + stepIndex: context.view.step, + steps + }) } if (context.view._tag === "AuthMenu") { diff --git a/packages/app/src/web/actions-projects.ts b/packages/app/src/web/actions-projects.ts index 5d1f845a..acf45da2 100644 --- a/packages/app/src/web/actions-projects.ts +++ b/packages/app/src/web/actions-projects.ts @@ -17,10 +17,10 @@ import { deleteProject, downAllProjects, downProject, - loadProjectTerminalSession, loadProjectDetails, loadProjectLogs, - loadProjectPs + loadProjectPs, + loadProjectTerminalSession } from "./api.js" import type { BrowserMenuTag } from "./menu.js" import { openProjectEventStream } from "./project-events.js" diff --git a/packages/app/src/web/api-schema.ts b/packages/app/src/web/api-schema.ts index 5c3453fe..8bd42294 100644 --- a/packages/app/src/web/api-schema.ts +++ b/packages/app/src/web/api-schema.ts @@ -302,9 +302,9 @@ export type { ProjectDatabaseProfile, ProjectDatabaseSession, ProjectDetails, - ProjectTerminalSessionLookup, ProjectPortForward, ProjectSummary, + ProjectTerminalSessionLookup, TerminalServerMessage, TerminalSession } from "./api-types.js" diff --git a/packages/app/src/web/api.ts b/packages/app/src/web/api.ts index e51bdc87..c4fc5cd0 100644 --- a/packages/app/src/web/api.ts +++ b/packages/app/src/web/api.ts @@ -14,9 +14,9 @@ import { ProjectEventsPollResponseSchema, ProjectPortForwardResponseSchema, ProjectPortForwardsResponseSchema, + ProjectsResponseSchema, ProjectTerminalSessionResponseSchema, ProjectTerminalSessionsResponseSchema, - ProjectsResponseSchema, TerminalSessionLookupResponseSchema, TerminalSessionResponseSchema } from "./api-schema.js" diff --git a/packages/app/src/web/app-ready-controller.ts b/packages/app/src/web/app-ready-controller.ts index 782f3267..123e0a76 100644 --- a/packages/app/src/web/app-ready-controller.ts +++ b/packages/app/src/web/app-ready-controller.ts @@ -1,8 +1,9 @@ import { updateActionPromptValue } from "./action-prompt.js" +import { withBusy } from "./actions-shared.js" import { + attachProjectTerminalById, cancelBrowserActionPrompt, closeSelectedProjectPort, - attachProjectTerminalById, connectProjectById, loadSelectedProjectBrowser, loadSelectedProjectPorts, @@ -12,7 +13,6 @@ import { submitBrowserActionPrompt } from "./actions.js" import { deleteProjectTerminalSession } from "./api.js" -import { withBusy } from "./actions-shared.js" import type { DashboardData } from "./api.js" import type { createActionContext } from "./app-ready-actions.js" import { resolveCurrentMenu, runAuthActionByIndex, runProjectAuthActionByIndex } from "./app-ready-actions.js" diff --git a/packages/app/src/web/app-ready-create.ts b/packages/app/src/web/app-ready-create.ts index d5355486..e3a41246 100644 --- a/packages/app/src/web/app-ready-create.ts +++ b/packages/app/src/web/app-ready-create.ts @@ -1,8 +1,13 @@ import { type Dispatch, type SetStateAction, useEffect } from "react" -import { nextBufferValue } from "../docker-git/menu-buffer-input.js" -import { advanceCreateFlow, type CreateFlowView, createInitialFlowView } from "../docker-git/menu-create-shared.js" import { formatParseError } from "../docker-git/cli/usage.js" +import { nextBufferValue } from "../docker-git/menu-buffer-input.js" +import { + advanceCreateFlow, + type CreateFlowView, + createInitialFlowView, + handleAdvanceCreateFlowResult +} from "../docker-git/menu-create-shared.js" import { submitCreateInputs } from "./actions-projects.js" import { requireGithubAuthConfigured } from "./actions-shared.js" import type { BrowserActionContext } from "./actions.js" @@ -49,8 +54,8 @@ export const submitCreateView = ( context, controllerCwd, createView, - quickCreate, projectsRoot, + quickCreate, setCreateView }: CreateSubmitArgs ): void => { @@ -62,20 +67,19 @@ export const submitCreateView = ( const next = quickCreate === undefined ? advanceCreateFlow(createContext, createView) : advanceCreateFlow(createContext, createView, { quickCreate }) - if (next === null) { - return - } - if (next._tag === "Error") { - context.setMessage(formatParseError(next.error)) - return - } - if (next._tag === "Continue") { - setCreateView(next.view) - context.setMessage(null) - return - } - submitCreateInputs(next.inputs, context) - setCreateView(resetCreateView()) + handleAdvanceCreateFlowResult(next, { + onError: (error) => { + context.setMessage(formatParseError(error)) + }, + onContinue: (view) => { + setCreateView(view) + context.setMessage(null) + }, + onComplete: (inputs) => { + submitCreateInputs(inputs, context) + setCreateView(resetCreateView()) + } + }) } export const useCreateMenuReset = ( diff --git a/packages/app/src/web/app-ready-layout.tsx b/packages/app/src/web/app-ready-layout.tsx index 4c5831df..b6d023d0 100644 --- a/packages/app/src/web/app-ready-layout.tsx +++ b/packages/app/src/web/app-ready-layout.tsx @@ -111,8 +111,12 @@ const headerPadding = (viewportLayout: ViewportLayout): number | string => const headerGap = (viewportLayout: ViewportLayout): number => viewportLayout.compact ? 1 : 2 const headerMetricsTopMargin = (viewportLayout: ViewportLayout): number | string => viewportLayout.compact ? "4px" : 1 -const terminalWorkspacePadding = (viewportLayout: ViewportLayout): number | string => - viewportLayout.mode === "mobile" ? 0 : viewportLayout.keyboardOpen ? "4px" : 1 +const terminalWorkspacePadding = (viewportLayout: ViewportLayout): string => { + if (viewportLayout.mode === "mobile") { + return "0px" + } + return viewportLayout.keyboardOpen ? "4px" : "8px" +} const HeaderTitle = ({ compact }: Pick): JSX.Element => ( diff --git a/packages/app/src/web/app-ready-ssh-link-hook.ts b/packages/app/src/web/app-ready-ssh-link-hook.ts index 5a894f7a..28cf5e55 100644 --- a/packages/app/src/web/app-ready-ssh-link-hook.ts +++ b/packages/app/src/web/app-ready-ssh-link-hook.ts @@ -1,13 +1,13 @@ import { Effect } from "effect" import { useEffect, useRef } from "react" -import { loadTerminalSessionById } from "./api.js" import type { BrowserActionContext } from "./actions-shared.js" +import { loadTerminalSessionById } from "./api.js" import type { DashboardData } from "./api.js" import { browserMenuIndex } from "./menu.js" import { projectPickerScreen } from "./screen.js" import { terminalSessionId } from "./terminal-state.js" -import { buildProjectActiveTerminalSession, type ActiveTerminalSession } from "./terminal.js" +import { type ActiveTerminalSession, buildProjectActiveTerminalSession } from "./terminal.js" type SshLinkArgs = { readonly actionContext: BrowserActionContext @@ -50,21 +50,33 @@ const decodePathTail = (value: string): string => .join("/") .trim() -const readSshLinkRequest = (): SshLinkRequest | null => { - const url = new URL(globalThis.location.href) - if (url.pathname.startsWith(sshPathPrefix)) { - const tail = url.pathname.slice(sshPathPrefix.length) - if (tail.startsWith("session/")) { - const sessionId = decodeURIComponent(tail.slice("session/".length).split("/")[0] ?? "").trim() - return sessionId.length === 0 ? null : { kind: "session", sessionId } - } - const decoded = decodePathTail(tail) - return decoded.length === 0 ? null : { kind: "project", token: decoded } +const readSessionPathRequest = (tail: string): SshLinkRequest | null => { + const sessionId = decodeURIComponent(tail.slice("session/".length).split("/")[0] ?? "").trim() + return sessionId.length === 0 ? null : { kind: "session", sessionId } +} + +const readSshPathRequest = (url: URL): SshLinkRequest | null => { + if (!url.pathname.startsWith(sshPathPrefix)) { + return null } + const tail = url.pathname.slice(sshPathPrefix.length) + if (tail.startsWith("session/")) { + return readSessionPathRequest(tail) + } + const decoded = decodePathTail(tail) + return decoded.length === 0 ? null : { kind: "project", token: decoded } +} + +const readSshQueryRequest = (url: URL): SshLinkRequest | null => { const queryToken = url.searchParams.get("ssh")?.trim() ?? "" return queryToken.length === 0 ? null : { kind: "project", token: queryToken } } +const readSshLinkRequest = (): SshLinkRequest | null => { + const url = new URL(globalThis.location.href) + return readSshPathRequest(url) ?? readSshQueryRequest(url) +} + const findProjectBySshToken = ( projects: DashboardData["projects"], token: string @@ -80,8 +92,10 @@ const showProjectTerminalScreen = (actionContext: BrowserActionContext, projectI const findLocalTerminalSession = ( sessions: ReadonlyArray, sessionId: string -): ActiveTerminalSession | undefined => - sessions.find((session) => terminalSessionId(session) === sessionId) +): ActiveTerminalSession | undefined => sessions.find((session) => terminalSessionId(session) === sessionId) + +const sshLinkRequestKey = (request: SshLinkRequest): string => + request.kind === "session" ? `session:${request.sessionId}` : `project:${request.token}` const scheduleTerminalSessionAttach = (args: SshLinkEffectArgs, sessionId: string): void => { clearConnectTimer(args.connectTimerRef) @@ -111,6 +125,31 @@ const scheduleTerminalSessionAttach = (args: SshLinkEffectArgs, sessionId: strin }, 0) } +const handleProjectSshLink = (args: SshLinkEffectArgs, request: { readonly token: string }): void => { + const project = findProjectBySshToken(args.projects, request.token) + if (project === undefined) { + args.actionContext.setMessage(`Project link was not found: ${request.token}.`) + return + } + clearConnectTimer(args.connectTimerRef) + showProjectTerminalScreen(args.actionContext, project.id) + args.deactivateTerminalWorkspace() +} + +const handleSessionSshLink = (args: SshLinkEffectArgs, request: { readonly sessionId: string }): void => { + const localSession = findLocalTerminalSession(args.terminalSessions, request.sessionId) + if (localSession === undefined) { + scheduleTerminalSessionAttach(args, request.sessionId) + return + } + clearConnectTimer(args.connectTimerRef) + if (localSession.browserProjectId !== undefined) { + showProjectTerminalScreen(args.actionContext, localSession.browserProjectId) + } + args.selectTerminalSession(request.sessionId) + args.actionContext.setMessage(`Opened existing SSH terminal: ${request.sessionId}.`) +} + const handleSshLinkEffect = (args: SshLinkEffectArgs): void => { const request = readSshLinkRequest() if (request === null) { @@ -118,35 +157,17 @@ const handleSshLinkEffect = (args: SshLinkEffectArgs): void => { args.handledTokenRef.current = null return } - const requestKey = request.kind === "session" ? `session:${request.sessionId}` : `project:${request.token}` + const requestKey = sshLinkRequestKey(request) if (args.busyLabel !== null || args.handledTokenRef.current === requestKey) { return } args.handledTokenRef.current = requestKey if (request.kind === "project") { - const project = findProjectBySshToken(args.projects, request.token) - if (project === undefined) { - args.actionContext.setMessage(`Project link was not found: ${request.token}.`) - return - } - clearConnectTimer(args.connectTimerRef) - showProjectTerminalScreen(args.actionContext, project.id) - args.deactivateTerminalWorkspace() - return - } - - const localSession = findLocalTerminalSession(args.terminalSessions, request.sessionId) - if (localSession !== undefined) { - clearConnectTimer(args.connectTimerRef) - if (localSession.browserProjectId !== undefined) { - showProjectTerminalScreen(args.actionContext, localSession.browserProjectId) - } - args.selectTerminalSession(request.sessionId) - args.actionContext.setMessage(`Opened existing SSH terminal: ${request.sessionId}.`) + handleProjectSshLink(args, request) return } - scheduleTerminalSessionAttach(args, request.sessionId) + handleSessionSshLink(args, request) } export const useSshLink = ({ diff --git a/packages/app/src/web/app-ready-terminal-screen.tsx b/packages/app/src/web/app-ready-terminal-screen.tsx index 731ea7ae..3739fc9e 100644 --- a/packages/app/src/web/app-ready-terminal-screen.tsx +++ b/packages/app/src/web/app-ready-terminal-screen.tsx @@ -38,7 +38,7 @@ type TerminalPaneProps = > & { readonly singleSession: boolean - readonly terminalSession: ActiveTerminalSession + readonly terminalSession: ActiveTerminalSession } const requestTerminalSessionClose = (closePath: string): void => { diff --git a/packages/app/src/web/app-ready-url.ts b/packages/app/src/web/app-ready-url.ts index 77a05f8f..91c1b887 100644 --- a/packages/app/src/web/app-ready-url.ts +++ b/packages/app/src/web/app-ready-url.ts @@ -4,7 +4,7 @@ import type { DashboardData } from "./api.js" import type { BrowserShortcutArgs } from "./app-ready-shortcut-runtime.js" import { browserMenuIndex, browserMenuItems, type BrowserMenuTag } from "./menu.js" import { type BrowserScreen, isProjectMenu, menuScreen, outputScreen, screenForMenu } from "./screen.js" -import { terminalSessionRoutePath, type ActiveTerminalSession } from "./terminal.js" +import { type ActiveTerminalSession, terminalSessionRoutePath } from "./terminal.js" type ReadyUrlNavigation = { readonly activeScreen: BrowserScreen @@ -78,6 +78,33 @@ const decodePathTail = (segments: ReadonlyArray): string => const projectToken = (project: DashboardData["projects"][number] | undefined, fallback: string | null): string | null => project?.projectKey ?? fallback +const activeTerminalReadyPath = (session: ActiveTerminalSession | null): string | null => { + if (session?.browserProjectId === undefined) { + return null + } + return session.sessionPath ?? terminalSessionRoutePath(session.session.id) +} + +const selectReadyPath = (token: string | null): string => + token === null ? "/menu/select" : `/ssh/${encodePathTail(token)}` + +const menuActionReadyPath = ( + activeScreen: BrowserScreen, + currentMenu: BrowserMenuTag, + token: string | null +): string => { + const slug = menuSlugs[currentMenu] + if (activeScreen.tag === "Menu") { + return `/menu/${slug}` + } + if (!isProjectMenu(currentMenu)) { + return `/${slug}` + } + const projectSuffix = token === null ? "" : `/${encodePathTail(token)}` + const outputSuffix = activeScreen.tag === "Output" ? "/output" : "" + return `/${slug}${projectSuffix}${outputSuffix}` +} + const resolveProjectId = ( projects: DashboardData["projects"], token: string @@ -168,28 +195,16 @@ export const readyUrlPath = ( selectedProjectSummary }: ReadyUrlPathArgs ): string | null => { - if (activeTerminalSession?.browserProjectId !== undefined) { - return activeTerminalSession.sessionPath ?? terminalSessionRoutePath(activeTerminalSession.session.id) + const terminalPath = activeTerminalReadyPath(activeTerminalSession) + if (terminalPath !== null) { + return terminalPath } + const token = projectToken(selectedProjectSummary, selectedProjectId) if (currentMenu === "Select") { - const token = projectToken(selectedProjectSummary, selectedProjectId) - return token === null ? "/menu/select" : `/ssh/${encodePathTail(token)}` - } - - const slug = menuSlugs[currentMenu] - if (activeScreen.tag === "Menu") { - return `/menu/${slug}` - } - - if (!isProjectMenu(currentMenu)) { - return `/${slug}` + return selectReadyPath(token) } - - const token = projectToken(selectedProjectSummary, selectedProjectId) - const projectSuffix = token === null ? "" : `/${encodePathTail(token)}` - const outputSuffix = activeScreen.tag === "Output" ? "/output" : "" - return `/${slug}${projectSuffix}${outputSuffix}` + return menuActionReadyPath(activeScreen, currentMenu, token) } const selectedProjectSummary = ({ dashboard, state }: ReadyUrlSyncArgs) => diff --git a/packages/app/src/web/app.tsx b/packages/app/src/web/app.tsx index 8626ad5e..5fc88a52 100644 --- a/packages/app/src/web/app.tsx +++ b/packages/app/src/web/app.tsx @@ -16,7 +16,7 @@ const resolveViewportSize = (): ViewportSize => { const layoutWidth = typeof globalThis.innerWidth === "number" ? globalThis.innerWidth : 1280 const visualViewport = globalThis.visualViewport - if (visualViewport === undefined || visualViewport === null) { + if (visualViewport === null) { return { height: layoutHeight, layoutHeight, diff --git a/packages/app/src/web/panel-content.tsx b/packages/app/src/web/panel-content.tsx index 4606eb31..f7cb0bf3 100644 --- a/packages/app/src/web/panel-content.tsx +++ b/packages/app/src/web/panel-content.tsx @@ -190,16 +190,17 @@ const renderContentBody = ( ), Match.when( "Select", - () => renderSelectContent({ - currentMenu: "Select", - dashboardRefreshTick, - onAttachProjectTerminalSession, - onKillProjectTerminalSession, - onOpenProjectTerminalById, - project, - projectNavigationArmed, - selectedProjectSummary - }) + () => + renderSelectContent({ + currentMenu: "Select", + dashboardRefreshTick, + onAttachProjectTerminalSession, + onKillProjectTerminalSession, + onOpenProjectTerminalById, + project, + projectNavigationArmed, + selectedProjectSummary + }) ), Match.when("Delete", () => renderProjectDetailsContent("Delete", project, selectedProjectSummary)), Match.when("Down", () => renderProjectDetailsContent("Down", project, selectedProjectSummary)), diff --git a/packages/app/src/web/panel-create-select.tsx b/packages/app/src/web/panel-create-select.tsx index e6692197..45600e3f 100644 --- a/packages/app/src/web/panel-create-select.tsx +++ b/packages/app/src/web/panel-create-select.tsx @@ -110,9 +110,11 @@ export const CreatePanel = ( {isRepoStep ? ( -