Skip to content

Commit ce5b2b9

Browse files
authored
fix(shell): surface clone source in workspace context (#374)
* fix(shell): surface clone source in workspace context * test(core): cover terminal label invariants * fix(shell): shorten terminal label context
1 parent df17fc9 commit ce5b2b9

10 files changed

Lines changed: 373 additions & 13 deletions

File tree

packages/api/src/services/terminal-sessions.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
appendTerminalOutput,
2828
createTerminalImagePastePlan,
2929
emptyTerminalOutputBuffer,
30+
projectTerminalLabel,
3031
renderTerminalOutputBuffer,
3132
terminalImagePasteDirectory,
3233
type TerminalImagePastePayload,
@@ -1399,7 +1400,7 @@ export const createTerminalSession = (
13991400
const session = yield* _(registerRecord(
14001401
resolvedProjectId,
14011402
project.projectKey,
1402-
project.displayName,
1403+
projectTerminalLabel(project),
14031404
prepared,
14041405
projectItem.containerName,
14051406
projectItem.targetDir,
@@ -1421,7 +1422,7 @@ export const createTerminalSession = (
14211422
const session = yield* _(registerRecord(
14221423
resolvedProjectId,
14231424
startedProject.projectKey,
1424-
startedProject.displayName,
1425+
projectTerminalLabel(startedProject),
14251426
prepared,
14261427
reachableProjectItem.containerName,
14271428
reachableProjectItem.targetDir,

packages/api/tests/terminal-sessions.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -414,7 +414,7 @@ describe("terminal sessions service", () => {
414414
status: "ready"
415415
})
416416
await expect(runTestEffect(lookupTerminalSessionById(second.session.id))).resolves.toMatchObject({
417-
projectDisplayName: displayName,
417+
projectDisplayName: "https://github.com/org/repo/issues/7 | container dg-repo-issue-7",
418418
projectKey,
419419
session: {
420420
id: second.session.id,

packages/app/src/docker-git/open-project-ssh.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { PlatformError } from "@effect/platform/Error"
22
import * as FileSystem from "@effect/platform/FileSystem"
33
import * as Path from "@effect/platform/Path"
4+
import { projectTerminalLabel } from "@prover-coder-ai/docker-git-terminal/core"
45
import { Duration, Effect } from "effect"
56

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

157158
const writeProjectSshHeader = (item: ProjectItem): Effect.Effect<void> =>
158159
Effect.sync(() => {
159-
writeToTerminal(`\n[docker-git] SSH terminal: ${item.displayName}\n`)
160+
writeToTerminal(`\n[docker-git] SSH terminal: ${projectTerminalLabel(item)}\n`)
160161
writeToTerminal(`[docker-git] ${item.sshCommand}\n\n`)
161162
})
162163

@@ -203,9 +204,9 @@ export const openResolvedProjectSshWithUpEffect = <E, R>(
203204
) =>
204205
Effect.gen(function*(_) {
205206
const writeProgress = deps.writeProgress ?? writeProjectOpenProgress
206-
yield* _(writeProgress(`Starting project before SSH: ${item.displayName}`))
207+
yield* _(writeProgress(`Starting project before SSH: ${projectTerminalLabel(item)}`))
207208
const refreshedItem = yield* _(deps.upProject(item.projectDir))
208-
yield* _(writeProgress(`Opening SSH terminal: ${(refreshedItem ?? item).displayName}`))
209+
yield* _(writeProgress(`Opening SSH terminal: ${projectTerminalLabel(refreshedItem ?? item)}`))
209210
yield* _(deps.openProjectSsh(refreshedItem ?? item))
210211
})
211212

@@ -241,7 +242,7 @@ export const openResolvedProjectSshViaController = (item: ProjectItem) =>
241242
createSession: (projectId) => createProjectTerminalSession(projectId),
242243
attach: (project, session) =>
243244
attachTerminalSession({
244-
header: `SSH terminal: ${project.displayName}`,
245+
header: `SSH terminal: ${projectTerminalLabel(project)}`,
245246
session,
246247
websocketPath: `/projects/${encodeURIComponent(project.projectDir)}/terminal-sessions/${
247248
encodeURIComponent(session.id)

packages/app/src/web/app-ready-controller-context.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { projectTerminalLabel } from "@prover-coder-ai/docker-git-terminal/core"
2+
13
import type { DashboardData } from "./api.js"
24
import { createActionContext } from "./app-ready-actions.js"
35
import type { ReadyState } from "./app-ready-hooks.js"
@@ -23,7 +25,7 @@ export const createReadyActionContext = (
2325
refreshDashboard,
2426
selectedProjectId: state.selectedProjectId,
2527
selectedProjectKey: selectedProjectSummary?.projectKey ?? null,
26-
selectedProjectName: selectedProjectSummary?.displayName ?? null,
28+
selectedProjectName: selectedProjectSummary === undefined ? null : projectTerminalLabel(selectedProjectSummary),
2729
setActionPrompt: state.setActionPrompt,
2830
setActiveScreen: state.setActiveScreen,
2931
setAuthSnapshot: state.setAuthSnapshot,

packages/app/src/web/app-ready-ssh-link-terminal.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { projectTerminalLabel } from "@prover-coder-ai/docker-git-terminal/core"
2+
13
import type { BrowserActionContext } from "./actions-shared.js"
24
import type { TerminalSession } from "./api-types.js"
35
import type { DashboardProject } from "./app-ready-ssh-link-core.js"
@@ -135,7 +137,7 @@ const buildProjectTerminalSession = (
135137
buildProjectActiveTerminalSession({
136138
onExit: args.actionContext.reloadDashboard,
137139
onReady: args.actionContext.reloadDashboard,
138-
projectDisplayName: project.displayName,
140+
projectDisplayName: projectTerminalLabel(project),
139141
projectId: project.id,
140142
projectKey: project.projectKey,
141143
session

packages/app/tests/docker-git/open-project-ssh.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,9 @@ describe("openResolvedProjectSshWithUpEffect", () => {
6969
})
7070
const events = yield* _(captureOpenResolvedProjectSshWithUpEvents(item))
7171
expect(events).toEqual([
72-
"progress:Starting project before SSH: org/repo",
72+
"progress:Starting project before SSH: https://github.com/org/repo.git | container dg-repo",
7373
"up:/controller/org/repo/issue-9",
74-
"progress:Opening SSH terminal: org/repo",
74+
"progress:Opening SSH terminal: https://github.com/org/repo.git | container dg-repo",
7575
"open:ssh -p 2299 dev@127.0.0.1"
7676
])
7777
}))
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export * from "./image-paste.js"
22
export * from "./output-buffer.js"
3+
export * from "./project-terminal-label.js"
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
export type ProjectTerminalLabelInput = {
2+
readonly containerName?: string | undefined
3+
readonly displayName: string
4+
readonly repoRef: string
5+
readonly repoUrl: string
6+
}
7+
8+
const decimalDigitsPattern = /^\d+$/u
9+
10+
const stripGitSuffix = (value: string): string => value.endsWith(".git") ? value.slice(0, -4) : value
11+
12+
const readPathPart = (value: string | undefined): string | null => {
13+
const trimmed = value?.trim() ?? ""
14+
return trimmed.length > 0 ? trimmed : null
15+
}
16+
17+
const splitGitHubRemotePath = (repoUrl: string): ReadonlyArray<string> | null => {
18+
const trimmed = repoUrl.trim()
19+
const httpsPrefix = "https://github.com/"
20+
const sshUrlPrefix = "ssh://git@github.com/"
21+
const sshScpPrefix = "git@github.com:"
22+
if (trimmed.startsWith(httpsPrefix)) {
23+
return trimmed.slice(httpsPrefix.length).split("/").filter((part) => part.length > 0)
24+
}
25+
if (trimmed.startsWith(sshUrlPrefix)) {
26+
return trimmed.slice(sshUrlPrefix.length).split("/").filter((part) => part.length > 0)
27+
}
28+
if (trimmed.startsWith(sshScpPrefix)) {
29+
return trimmed.slice(sshScpPrefix.length).split("/").filter((part) => part.length > 0)
30+
}
31+
return null
32+
}
33+
34+
const githubRepositoryPath = (repoUrl: string): string | null => {
35+
const parts = splitGitHubRemotePath(repoUrl)
36+
const owner = readPathPart(parts?.[0])
37+
const repoRaw = readPathPart(parts?.[1])
38+
if (owner === null || repoRaw === null) {
39+
return null
40+
}
41+
return `${owner}/${stripGitSuffix(repoRaw)}`
42+
}
43+
44+
const sourceUrlForContext = (repoUrl: string, path: string): string | null => {
45+
const repoPath = githubRepositoryPath(repoUrl)
46+
return repoPath === null ? null : `https://github.com/${repoPath}/${path}`
47+
}
48+
49+
const renderIssueContext = (repoUrl: string, issueId: string): string => {
50+
const issueUrl = sourceUrlForContext(repoUrl, `issues/${issueId}`)
51+
return issueUrl === null ? `issue #${issueId}` : issueUrl
52+
}
53+
54+
const renderPullRequestContext = (repoUrl: string, pullRequestId: string): string => {
55+
const pullRequestUrl = sourceUrlForContext(repoUrl, `pull/${pullRequestId}`)
56+
return pullRequestUrl === null ? `PR #${pullRequestId}` : pullRequestUrl
57+
}
58+
59+
const renderMergeRequestContext = (mergeRequestId: string): string => `MR #${mergeRequestId}`
60+
61+
const renderSourceContext = (repoUrl: string, repoRef: string): string => {
62+
const trimmedUrl = repoUrl.trim()
63+
const trimmedRef = repoRef.trim()
64+
if (trimmedUrl.length === 0) {
65+
return trimmedRef.length === 0 || trimmedRef === "main" ? "" : trimmedRef
66+
}
67+
return trimmedRef.length === 0 || trimmedRef === "main"
68+
? trimmedUrl
69+
: `${trimmedUrl} (${trimmedRef})`
70+
}
71+
72+
const parseWrappedNumericRef = (value: string, prefix: string, suffix: string): string | null => {
73+
if (!value.startsWith(prefix) || !value.endsWith(suffix)) {
74+
return null
75+
}
76+
const id = value.slice(prefix.length, value.length - suffix.length)
77+
return decimalDigitsPattern.test(id) ? id : null
78+
}
79+
80+
const renderWorkspaceContext = (
81+
repoUrl: string,
82+
repoRef: string
83+
): string => {
84+
const issueId = parseWrappedNumericRef(repoRef, "issue-", "")
85+
if (issueId !== null) {
86+
return renderIssueContext(repoUrl, issueId)
87+
}
88+
const pullRequestId = parseWrappedNumericRef(repoRef, "refs/pull/", "/head")
89+
if (pullRequestId !== null) {
90+
return renderPullRequestContext(repoUrl, pullRequestId)
91+
}
92+
const mergeRequestId = parseWrappedNumericRef(repoRef, "refs/merge-requests/", "/head")
93+
if (mergeRequestId !== null) {
94+
return renderMergeRequestContext(mergeRequestId)
95+
}
96+
return renderSourceContext(repoUrl, repoRef)
97+
}
98+
99+
const appendNonEmpty = (parts: ReadonlyArray<string>, value: string): ReadonlyArray<string> => {
100+
const trimmed = value.trim()
101+
return trimmed.length === 0 ? parts : [...parts, trimmed]
102+
}
103+
104+
/**
105+
* Builds the terminal-facing project label with source link and container identity.
106+
*
107+
* @param project - Project identity returned by the docker-git API.
108+
* @returns A deterministic label for SSH terminal headers and ready messages.
109+
*
110+
* @pure true
111+
* @effect none
112+
* @invariant GitHub issue/PR refs prefer canonical source URLs; labels preserve non-empty containerName.
113+
* @precondition project.displayName identifies the repository or fallback project label.
114+
* @postcondition result contains workspace source link/context and non-empty containerName when present.
115+
* @complexity O(n) where n = |repoUrl| + |repoRef|
116+
* @throws Never
117+
*/
118+
// CHANGE: keep SSH terminal labels to source link/context plus container identity
119+
// WHY: verbose repository + issue text duplicates the source URL and crowds the terminal header
120+
// QUOTE(ТЗ): "ссылки и название контейнера будет предостаточно"
121+
// REF: issue-370
122+
// SOURCE: n/a
123+
// FORMAT THEOREM: forall p: label(p) contains context(repoUrl(p), repoRef(p)) or containerName(p)
124+
// PURITY: CORE
125+
// EFFECT: none
126+
// INVARIANT: issue-* -> issue context; refs/pull/*/head -> PR context; containerName is preserved when non-empty
127+
// COMPLEXITY: O(n)
128+
export const projectTerminalLabel = (project: ProjectTerminalLabelInput): string => {
129+
const withContext = appendNonEmpty([], renderWorkspaceContext(project.repoUrl, project.repoRef))
130+
const containerName = project.containerName?.trim() ?? ""
131+
const withContainer = containerName.length === 0
132+
? withContext
133+
: appendNonEmpty(withContext, `container ${containerName}`)
134+
if (withContainer.length > 0) {
135+
return withContainer.join(" | ")
136+
}
137+
const displayName = project.displayName.trim()
138+
return displayName.length === 0 ? project.repoUrl.trim() : displayName
139+
}

0 commit comments

Comments
 (0)