Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions packages/api/src/services/terminal-sessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
appendTerminalOutput,
createTerminalImagePastePlan,
emptyTerminalOutputBuffer,
projectTerminalLabel,
renderTerminalOutputBuffer,
terminalImagePasteDirectory,
type TerminalImagePastePayload,
Expand Down Expand Up @@ -1399,7 +1400,7 @@ export const createTerminalSession = (
const session = yield* _(registerRecord(
resolvedProjectId,
project.projectKey,
project.displayName,
projectTerminalLabel(project),
prepared,
projectItem.containerName,
projectItem.targetDir,
Expand All @@ -1421,7 +1422,7 @@ export const createTerminalSession = (
const session = yield* _(registerRecord(
resolvedProjectId,
startedProject.projectKey,
startedProject.displayName,
projectTerminalLabel(startedProject),
prepared,
reachableProjectItem.containerName,
reachableProjectItem.targetDir,
Expand Down
2 changes: 1 addition & 1 deletion packages/api/tests/terminal-sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,7 +414,7 @@ describe("terminal sessions service", () => {
status: "ready"
})
await expect(runTestEffect(lookupTerminalSessionById(second.session.id))).resolves.toMatchObject({
projectDisplayName: displayName,
projectDisplayName: "org/repo | issue #7 (https://github.com/org/repo/issues/7) | container dg-repo-issue-7",
projectKey,
session: {
id: second.session.id,
Expand Down
9 changes: 5 additions & 4 deletions packages/app/src/docker-git/open-project-ssh.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { PlatformError } from "@effect/platform/Error"
import * as FileSystem from "@effect/platform/FileSystem"
import * as Path from "@effect/platform/Path"
import { projectTerminalLabel } from "@prover-coder-ai/docker-git-terminal/core"
import { Duration, Effect } from "effect"

import { createProjectTerminalSession, upProject } from "./api-client.js"
Expand Down Expand Up @@ -156,7 +157,7 @@ const resolveHostSshLaunchSpec = (

const writeProjectSshHeader = (item: ProjectItem): Effect.Effect<void> =>
Effect.sync(() => {
writeToTerminal(`\n[docker-git] SSH terminal: ${item.displayName}\n`)
writeToTerminal(`\n[docker-git] SSH terminal: ${projectTerminalLabel(item)}\n`)
writeToTerminal(`[docker-git] ${item.sshCommand}\n\n`)
})

Expand Down Expand Up @@ -203,9 +204,9 @@ export const openResolvedProjectSshWithUpEffect = <E, R>(
) =>
Effect.gen(function*(_) {
const writeProgress = deps.writeProgress ?? writeProjectOpenProgress
yield* _(writeProgress(`Starting project before SSH: ${item.displayName}`))
yield* _(writeProgress(`Starting project before SSH: ${projectTerminalLabel(item)}`))
const refreshedItem = yield* _(deps.upProject(item.projectDir))
yield* _(writeProgress(`Opening SSH terminal: ${(refreshedItem ?? item).displayName}`))
yield* _(writeProgress(`Opening SSH terminal: ${projectTerminalLabel(refreshedItem ?? item)}`))
yield* _(deps.openProjectSsh(refreshedItem ?? item))
})

Expand Down Expand Up @@ -241,7 +242,7 @@ export const openResolvedProjectSshViaController = (item: ProjectItem) =>
createSession: (projectId) => createProjectTerminalSession(projectId),
attach: (project, session) =>
attachTerminalSession({
header: `SSH terminal: ${project.displayName}`,
header: `SSH terminal: ${projectTerminalLabel(project)}`,
session,
websocketPath: `/projects/${encodeURIComponent(project.projectDir)}/terminal-sessions/${
encodeURIComponent(session.id)
Expand Down
4 changes: 3 additions & 1 deletion packages/app/src/web/app-ready-controller-context.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { projectTerminalLabel } from "@prover-coder-ai/docker-git-terminal/core"

import type { DashboardData } from "./api.js"
import { createActionContext } from "./app-ready-actions.js"
import type { ReadyState } from "./app-ready-hooks.js"
Expand All @@ -23,7 +25,7 @@ export const createReadyActionContext = (
refreshDashboard,
selectedProjectId: state.selectedProjectId,
selectedProjectKey: selectedProjectSummary?.projectKey ?? null,
selectedProjectName: selectedProjectSummary?.displayName ?? null,
selectedProjectName: selectedProjectSummary === undefined ? null : projectTerminalLabel(selectedProjectSummary),
setActionPrompt: state.setActionPrompt,
setActiveScreen: state.setActiveScreen,
setAuthSnapshot: state.setAuthSnapshot,
Expand Down
4 changes: 3 additions & 1 deletion packages/app/src/web/app-ready-ssh-link-terminal.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { projectTerminalLabel } from "@prover-coder-ai/docker-git-terminal/core"

import type { BrowserActionContext } from "./actions-shared.js"
import type { TerminalSession } from "./api-types.js"
import type { DashboardProject } from "./app-ready-ssh-link-core.js"
Expand Down Expand Up @@ -135,7 +137,7 @@ const buildProjectTerminalSession = (
buildProjectActiveTerminalSession({
onExit: args.actionContext.reloadDashboard,
onReady: args.actionContext.reloadDashboard,
projectDisplayName: project.displayName,
projectDisplayName: projectTerminalLabel(project),
projectId: project.id,
projectKey: project.projectKey,
session
Expand Down
4 changes: 2 additions & 2 deletions packages/app/tests/docker-git/open-project-ssh.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,9 @@ describe("openResolvedProjectSshWithUpEffect", () => {
})
const events = yield* _(captureOpenResolvedProjectSshWithUpEvents(item))
expect(events).toEqual([
"progress:Starting project before SSH: org/repo",
"progress:Starting project before SSH: org/repo | source https://github.com/org/repo.git | container dg-repo",
"up:/controller/org/repo/issue-9",
"progress:Opening SSH terminal: org/repo",
"progress:Opening SSH terminal: org/repo | source https://github.com/org/repo.git | container dg-repo",
"open:ssh -p 2299 dev@127.0.0.1"
])
}))
Expand Down
1 change: 1 addition & 0 deletions packages/terminal/src/core/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./image-paste.js"
export * from "./output-buffer.js"
export * from "./project-terminal-label.js"
133 changes: 133 additions & 0 deletions packages/terminal/src/core/project-terminal-label.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
export type ProjectTerminalLabelInput = {
readonly containerName?: string | undefined
readonly displayName: string
readonly repoRef: string
readonly repoUrl: string
}

const decimalDigitsPattern = /^\d+$/u

const stripGitSuffix = (value: string): string => value.endsWith(".git") ? value.slice(0, -4) : value

const readPathPart = (value: string | undefined): string | null => {
const trimmed = value?.trim() ?? ""
return trimmed.length > 0 ? trimmed : null
}

const splitGitHubRemotePath = (repoUrl: string): ReadonlyArray<string> | null => {
const trimmed = repoUrl.trim()
const httpsPrefix = "https://github.com/"
const sshUrlPrefix = "ssh://git@github.com/"
const sshScpPrefix = "git@github.com:"
if (trimmed.startsWith(httpsPrefix)) {
return trimmed.slice(httpsPrefix.length).split("/").filter((part) => part.length > 0)
}
if (trimmed.startsWith(sshUrlPrefix)) {
return trimmed.slice(sshUrlPrefix.length).split("/").filter((part) => part.length > 0)
}
if (trimmed.startsWith(sshScpPrefix)) {
return trimmed.slice(sshScpPrefix.length).split("/").filter((part) => part.length > 0)
}
return null
}

const githubRepositoryPath = (repoUrl: string): string | null => {
const parts = splitGitHubRemotePath(repoUrl)
const owner = readPathPart(parts?.[0])
const repoRaw = readPathPart(parts?.[1])
if (owner === null || repoRaw === null) {
return null
}
return `${owner}/${stripGitSuffix(repoRaw)}`
}

const sourceUrlForContext = (repoUrl: string, path: string): string | null => {
const repoPath = githubRepositoryPath(repoUrl)
return repoPath === null ? null : `https://github.com/${repoPath}/${path}`
}

const renderIssueContext = (repoUrl: string, issueId: string): string => {
const issueUrl = sourceUrlForContext(repoUrl, `issues/${issueId}`)
return issueUrl === null ? `issue #${issueId}` : `issue #${issueId} (${issueUrl})`
}

const renderPullRequestContext = (repoUrl: string, pullRequestId: string): string => {
const pullRequestUrl = sourceUrlForContext(repoUrl, `pull/${pullRequestId}`)
return pullRequestUrl === null ? `PR #${pullRequestId}` : `PR #${pullRequestId} (${pullRequestUrl})`
}

const renderMergeRequestContext = (mergeRequestId: string): string => `MR #${mergeRequestId}`

const renderSourceContext = (repoUrl: string, repoRef: string): string => {
const trimmedRef = repoRef.trim()
return trimmedRef.length === 0 || trimmedRef === "main"
? `source ${repoUrl.trim()}`
: `source ${repoUrl.trim()} (${trimmedRef})`
}

const parseWrappedNumericRef = (value: string, prefix: string, suffix: string): string | null => {
if (!value.startsWith(prefix) || !value.endsWith(suffix)) {
return null
}
const id = value.slice(prefix.length, value.length - suffix.length)
return decimalDigitsPattern.test(id) ? id : null
}

const renderWorkspaceContext = (
repoUrl: string,
repoRef: string
): string => {
const issueId = parseWrappedNumericRef(repoRef, "issue-", "")
if (issueId !== null) {
return renderIssueContext(repoUrl, issueId)
}
const pullRequestId = parseWrappedNumericRef(repoRef, "refs/pull/", "/head")
if (pullRequestId !== null) {
return renderPullRequestContext(repoUrl, pullRequestId)
}
const mergeRequestId = parseWrappedNumericRef(repoRef, "refs/merge-requests/", "/head")
if (mergeRequestId !== null) {
return renderMergeRequestContext(mergeRequestId)
}
return renderSourceContext(repoUrl, repoRef)
}

const appendNonEmpty = (parts: ReadonlyArray<string>, value: string): ReadonlyArray<string> => {
const trimmed = value.trim()
return trimmed.length === 0 ? parts : [...parts, trimmed]
}

/**
* Builds the terminal-facing project label with source workspace context.
*
* @param project - Project identity returned by the docker-git API.
* @returns A deterministic label for SSH terminal headers and ready messages.
*
* @pure true
* @effect none
* @invariant issue refs include an issue marker; PR refs include a PR marker; labels never omit displayName.
* @precondition project.displayName identifies the repository or fallback project label.
* @postcondition result contains displayName, workspace source context, and non-empty containerName when present.
* @complexity O(n) where n = |repoUrl| + |repoRef|
* @throws Never
*/
// CHANGE: surface clone-source context in SSH terminal labels
// WHY: terminal headers must identify issue/PR source and container instead of only the repo path
// QUOTE(ТЗ): "надо писать какой Issues какой PR вообещ что за конетейнер"
// REF: issue-370
// SOURCE: n/a
// FORMAT THEOREM: forall p: label(p) contains displayName(p) and context(repoUrl(p), repoRef(p))
// PURITY: CORE
// EFFECT: none
// INVARIANT: issue-* -> issue context; refs/pull/*/head -> PR context; containerName is preserved when non-empty
// COMPLEXITY: O(n)
export const projectTerminalLabel = (project: ProjectTerminalLabelInput): string => {
const displayName = project.displayName.trim()
const baseName = displayName.length === 0 ? project.repoUrl.trim() : displayName
const withContext = appendNonEmpty([baseName], renderWorkspaceContext(project.repoUrl, project.repoRef))
const containerName = project.containerName?.trim() ?? ""
const withContainer = containerName.length === 0
? withContext
: appendNonEmpty(withContext, `container ${containerName}`)
return withContainer.join(" | ")
}
Loading
Loading