Skip to content

Commit 5a3d3e9

Browse files
fix(workspace): open the link pickers on a workspace that already has the project's name
When no workspace is linked, both link pickers opened on "create a quick workspace", even when the list showed a workspace with exactly the name the create would use. Enter then made a second workspace with that name, splitting the team's skills and memory between the two. - `sameNamedWorkspace()` (workspace-name.ts) finds a listed workspace whose name matches the proposed one, comparing case- and whitespace-insensitively. - `altimate-code link` and the TUI's on-demand picker open on that workspace and mark it "same name as this project". - Creating a workspace with a name that is already listed asks for confirmation first (defaulting to no in the CLI). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0172qrhMa5TQgETASi5hxMqD
1 parent 8fe2e0c commit 5a3d3e9

4 files changed

Lines changed: 103 additions & 6 deletions

File tree

‎packages/opencode/src/altimate/workspace/workspace-name.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,26 @@ export function workspaceLabel(name: string, id: string | undefined, budget = MA
4747
// so it is what survives.
4848
return label.length > budget ? suffix.trim() : label
4949
}
50+
51+
/** A name as the link pickers compare it: control characters and runs of whitespace
52+
* collapsed, trimmed, case folded. */
53+
function comparableName(name: string): string {
54+
return name
55+
.replace(/[\u0000-\u001F\u007F-\u009F\u2028\u2029]+/g, " ")
56+
.replace(/\s+/g, " ")
57+
.trim()
58+
.toLowerCase()
59+
}
60+
61+
/** The first listed workspace already named what a quick create would call this
62+
* project, or undefined. The link pickers open on it instead of on "create", and
63+
* creating a second workspace with that name takes a confirmation: two workspaces
64+
* with one name split a team's skills and memory without anyone noticing. */
65+
export function sameNamedWorkspace<T extends { name: string }>(
66+
list: readonly T[],
67+
proposedName: string,
68+
): T | undefined {
69+
const target = comparableName(proposedName)
70+
if (!target) return undefined
71+
return list.find((workspace) => comparableName(workspace.name) === target)
72+
}

‎packages/opencode/src/cli/cmd/link.ts‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {
4343
} from "@/altimate/workspace/browser-handoff"
4444
import { accountDigest, recordApprovedBinding } from "@/altimate/workspace/state"
4545
import type { SeedOutcome } from "@/altimate/workspace/memory-backfill"
46+
import { sameNamedWorkspace } from "@/altimate/workspace/workspace-name"
4647

4748
const CREATE_NEW_SENTINEL = "__create_new__"
4849
const SET_UP_IN_BROWSER_SENTINEL = "__browser_handoff__"
@@ -245,6 +246,9 @@ export const LinkCommand = cmd({
245246
? projectNameFromRemote(identifier.repoRemote)
246247
: projectNameFromPath(identifier.projectPath)
247248
const currentId = existing?.datamate.id
249+
// A listed workspace already named what a quick create would use. The picker
250+
// opens on it rather than on create, and creating a namesake is confirmed.
251+
const namesake = sameNamedWorkspace(list, autoName)
248252
// Sanitized once here so every downstream display (the picker message,
249253
// the "Kept" outro, hyperlink()'s own text) is covered — hyperlink()
250254
// only sanitized its own `text` param, not the raw name reaching
@@ -312,7 +316,12 @@ export const LinkCommand = cmd({
312316
return {
313317
value: String(dm.id),
314318
label: dm.id === currentId ? `● ${hyperlink(safeDmName, currentManageUrl)}` : ` ${safeDmName}`,
315-
hint: dm.id === currentId ? "currently linked here" : undefined,
319+
hint:
320+
dm.id === currentId
321+
? "currently linked here"
322+
: dm.id === namesake?.id
323+
? "same name as this project"
324+
: undefined,
316325
}
317326
}),
318327
]
@@ -322,7 +331,7 @@ export const LinkCommand = cmd({
322331
? `Currently linked to "${hyperlink(currentName!, currentManageUrl)}". Pick a workspace (or create a new one):`
323332
: "Pick a workspace to link (or create a new one):",
324333
options,
325-
initialValue: currentId !== undefined ? String(currentId) : CREATE_NEW_SENTINEL,
334+
initialValue: currentId !== undefined ? String(currentId) : namesake ? String(namesake.id) : CREATE_NEW_SENTINEL,
326335
})
327336

328337
if (prompts.isCancel(pick)) {
@@ -336,6 +345,16 @@ export const LinkCommand = cmd({
336345
}
337346

338347
if (pick === CREATE_NEW_SENTINEL) {
348+
if (namesake) {
349+
const again = await prompts.confirm({
350+
message: `A workspace named "${stripControlChars(namesake.name)}" already exists. Create another one with the same name?`,
351+
initialValue: false,
352+
})
353+
if (prompts.isCancel(again) || !again) {
354+
prompts.outro("No changes.")
355+
return
356+
}
357+
}
339358
await createThenBindOrRebind(identifier, autoName, args.directory, existing)
340359
return
341360
}

‎packages/opencode/src/plugin/tui/altimate/workspace.tsx‎

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import open from "open"
2929
// altimate_change start - the /workspace action menu
3030
import * as Manage from "@/altimate/workspace/manage"
3131
import { describeSyncProblems } from "@/altimate/workspace/skill-sync"
32-
import { inertWorkspaceName } from "@/altimate/workspace/workspace-name"
32+
import { inertWorkspaceName, sameNamedWorkspace } from "@/altimate/workspace/workspace-name"
3333
// altimate_change end
3434
import { createSignal, onCleanup, onMount } from "solid-js"
3535
import {
@@ -998,6 +998,13 @@ function OnDemandPickerDialog(props: OnDemandPickerProps) {
998998
}
999999
})
10001000

1001+
// A listed workspace already named what a quick create would use: the picker
1002+
// opens on it, and creating a namesake is confirmed first.
1003+
const namesake = () => {
1004+
const list = datamates()
1005+
return list ? sameNamedWorkspace(list, props.defaultName) : undefined
1006+
}
1007+
10011008
const options = () => {
10021009
const list = datamates()
10031010
// No ``disabled: true`` — DialogSelect filters those out (Kilo cycle 6).
@@ -1014,7 +1021,12 @@ function OnDemandPickerDialog(props: OnDemandPickerProps) {
10141021
...list.map((dm) => ({
10151022
title: dm.id === props.currentlyLinkedDatamateId ? `● ${dm.name}` : ` ${dm.name}`,
10161023
value: dm.id,
1017-
description: dm.id === props.currentlyLinkedDatamateId ? "currently linked to this project" : undefined,
1024+
description:
1025+
dm.id === props.currentlyLinkedDatamateId
1026+
? "currently linked to this project"
1027+
: dm.id === namesake()?.id
1028+
? "same name as this project"
1029+
: undefined,
10181030
})),
10191031
]
10201032
}
@@ -1023,7 +1035,7 @@ function OnDemandPickerDialog(props: OnDemandPickerProps) {
10231035
<props.api.ui.DialogSelect<number>
10241036
title="Link this project to a workspace"
10251037
options={options()}
1026-
current={props.currentlyLinkedDatamateId ?? CREATE_NEW_SENTINEL}
1038+
current={props.currentlyLinkedDatamateId ?? namesake()?.id ?? CREATE_NEW_SENTINEL}
10271039
onSelect={(option) => {
10281040
if (option.value === -1) {
10291041
props.api.ui.dialog.clear()
@@ -1041,7 +1053,20 @@ function OnDemandPickerDialog(props: OnDemandPickerProps) {
10411053
matchedBy: props.matchedBy,
10421054
}
10431055
: undefined
1044-
void createAndBindInline(props.api, props.identifier, props.defaultName, rebindFrom)
1056+
const create = () => void createAndBindInline(props.api, props.identifier, props.defaultName, rebindFrom)
1057+
const twin = namesake()
1058+
if (!twin) {
1059+
create()
1060+
return
1061+
}
1062+
props.api.ui.dialog.replace(() => (
1063+
<props.api.ui.DialogConfirm
1064+
title="Create a workspace with the same name?"
1065+
message={`A workspace named "${inertWorkspaceName(twin.name)}" already exists. Create another one called "${props.defaultName}"?`}
1066+
onConfirm={create}
1067+
onCancel={() => props.api.ui.dialog.clear()}
1068+
/>
1069+
))
10451070
return
10461071
}
10471072
// Picked an existing workspace.
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// altimate_change - new file
2+
//
3+
// The link pickers open on a listed workspace that already has the name a quick
4+
// create would use, instead of on "create", so a second workspace with the same name
5+
// takes a deliberate choice.
6+
import { describe, expect, test } from "bun:test"
7+
import { sameNamedWorkspace } from "../../../src/altimate/workspace/workspace-name"
8+
9+
const ws = (id: number, name: string) => ({ id, name })
10+
11+
describe("sameNamedWorkspace", () => {
12+
test.each([
13+
["exact name", [ws(1, "analytics")], "analytics", 1],
14+
["case differs", [ws(1, "Analytics")], "analytics", 1],
15+
["surrounding and inner whitespace differ", [ws(1, " data platform ")], "data platform", 1],
16+
["a control character in the listed name", [ws(1, "analy\u0007tics")], "analy tics", 1],
17+
["no workspace has the name", [ws(1, "marketing"), ws(2, "finance")], "analytics", undefined],
18+
["a longer name that only starts the same", [ws(1, "analytics-prod")], "analytics", undefined],
19+
["the first of two namesakes wins", [ws(3, "analytics"), ws(4, "ANALYTICS")], "analytics", 3],
20+
["an empty proposed name matches nothing", [ws(1, "")], " ", undefined],
21+
["an empty list", [], "analytics", undefined],
22+
] as const)("%s", (_label, list, proposed, expected) => {
23+
expect(sameNamedWorkspace(list, proposed)?.id).toBe(expected)
24+
})
25+
26+
test("returns the listed object itself, so callers keep its id and display name", () => {
27+
const listed = ws(7, "Analytics")
28+
expect(sameNamedWorkspace([ws(1, "other"), listed], "analytics")).toBe(listed)
29+
})
30+
})

0 commit comments

Comments
 (0)