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/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 7ed3a369..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 = ( @@ -279,9 +316,12 @@ const parseRepoStepInput = ( }) } -const createStepApplied = (): Either.Either => Either.right(true as const) +const createStepApplied = (): Either.Either => { + 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 = ( @@ -432,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/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/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 ? ( -