Skip to content
Merged
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
23 changes: 21 additions & 2 deletions packages/opencode/src/altimate/workspace/api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,14 @@ export class WorkspaceApiError extends Error {
}
}

/** A credential captured once and passed to every request of one flow, so the flow cannot
* drift onto another account part-way: workspace and user ids are per tenant. */
export interface ActAs {
url: string
instance: string
apiKey: string
}

async function creds(): Promise<{ url: string; instance: string; apiKey: string }> {
if (!(await AltimateApi.isConfigured())) throw new NotConfiguredError()
const c = await AltimateApi.getCredentials()
Expand Down Expand Up @@ -521,23 +529,31 @@ export namespace WorkspaceApi {
return { id, name: input.name }
}

/** The ambient credential as an `ActAs`, or null when none is configured. */
export async function captureCredentials(): Promise<ActAs | null> {
return creds().catch(() => null)
}

export async function bindExisting(
datamateId: number,
identifier: ProjectIdentifier,
actAs?: ActAs,
): Promise<BindingResponse> {
return req<BindingResponse>("POST", "/bind", {
body: {
datamate_id: datamateId,
repo_remote: identifier.repoRemote ?? null,
project_path: identifier.projectPath ?? null,
},
...(actAs ? { actAs } : {}),
})
}

export async function rebindByRemote(input: {
remote: string
targetDatamateId: number
expectedCurrentDatamateId?: number
actAs?: ActAs
}): Promise<BindingResponse> {
return req<BindingResponse>("PUT", "/by-remote", {
body: {
Expand All @@ -547,6 +563,7 @@ export namespace WorkspaceApi {
? { expected_current_datamate_id: input.expectedCurrentDatamateId }
: {}),
},
...(input.actAs ? { actAs: input.actAs } : {}),
})
}

Expand All @@ -556,6 +573,7 @@ export namespace WorkspaceApi {
projectPath: string
targetDatamateId: number
expectedCurrentDatamateId?: number
actAs?: ActAs
}): Promise<BindingResponse> {
return req<BindingResponse>("PUT", "/by-path", {
body: {
Expand All @@ -565,6 +583,7 @@ export namespace WorkspaceApi {
? { expected_current_datamate_id: input.expectedCurrentDatamateId }
: {}),
},
...(input.actAs ? { actAs: input.actAs } : {}),
})
}

Expand Down Expand Up @@ -627,8 +646,8 @@ export namespace WorkspaceApi {
/** The caller's own user id, from ``GET /users/me``. Needed wherever the
* client must compare ownership — skill attachment requires the caller to
* OWN the workspace, and the credentials carry no user id of their own. */
export async function whoami(): Promise<number> {
const me = await req<{ id?: unknown }>("GET", "/me", { base: "/users" })
export async function whoami(actAs?: ActAs): Promise<number> {
const me = await req<{ id?: unknown }>("GET", "/me", { base: "/users", ...(actAs ? { actAs } : {}) })
const id = Number(me?.id)
if (!Number.isInteger(id) || id <= 0) throw new WorkspaceApiError("The server did not say who this account is.")
return id
Expand Down
95 changes: 90 additions & 5 deletions packages/opencode/src/altimate/workspace/workspace-name.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
//
// The one piece of workspace text handling that BOTH realms need: the session
// code renders the name into the system prompt, and the TUI plugin renders it
// into a dialog header. Kept free of imports and module state on purpose —
// into a dialog header. Kept free of imports and mutable module state on purpose —
// `precedence.ts`, where this lived, is server-side only (see its header), and
// a plugin importing it would load a second copy of that module's state into
// the plugin realm.
Expand All @@ -15,10 +15,7 @@
* what the model reads. */
export const MAX_WORKSPACE_NAME_CHARS = 80
export function inertWorkspaceName(name: string): string {
const cleaned = name
.replace(/[\u0000-\u001F\u007F-\u009F\u2028\u2029]+/g, " ")
.replace(/\s+/g, " ")
.trim()
const cleaned = oneLine(name)
const points = Array.from(cleaned)
return points.length > MAX_WORKSPACE_NAME_CHARS ? points.slice(0, MAX_WORKSPACE_NAME_CHARS - 1).join("") + "…" : cleaned
}
Expand Down Expand Up @@ -47,3 +44,91 @@ export function workspaceLabel(name: string, id: string | undefined, budget = MA
// so it is what survives.
return label.length > budget ? suffix.trim() : label
}

/** A name on one line: control characters (C0, DEL and C1) and the Unicode line and
* paragraph separators become spaces, runs of whitespace collapse, ends are trimmed.
* The one normalisation both the rendered and the compared name start from. */
function oneLine(name: string): string {
return name
.replace(/[\u0000-\u001F\u007F-\u009F\u2028\u2029]+/g, " ")
.replace(/\s+/g, " ")
.trim()
}

/** A name as the link pickers compare it: on one line, and the sharp S spelled out,
* which collation otherwise keeps apart from "ss". */
function comparableName(name: string): string {
return oneLine(name).replace(/[ßẞ]/g, "ss")
}

/** The bidi controls (ALM, LRM/RLM, LRE..RLO, LRI..PDI): harmless in a link, but they
* can visually reverse or reorder a displayed name. */
const BIDI_CONTROLS = /[\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g
export function stripBidiControls(text: string): string {
return text.replace(BIDI_CONTROLS, "")
}

/** A workspace name as a dialog shows it: inert, and with no bidi controls. */
export function displayWorkspaceName(name: string): string {
return stripBidiControls(inertWorkspaceName(name))
}

/** One collator for every comparison, pinned to `en`: an unpinned one follows the host
* locale, and under Turkish `I` no longer pairs with `i`, under Danish `aa` equals `å`. */
export const NAME_COLLATOR = new Intl.Collator("en", { sensitivity: "accent", usage: "search" })

export interface Namesakes<T> {
/** Every listed workspace with the name a quick create would use, in list order. */
all: T[]
/** The first of them the caller owns, or undefined. The only one a picker opens on: a plain
* Enter on a namesake links to it and sends this machine's memory there, which must not
* happen to a colleague's workspace by accident. */
own: T | undefined
}

/** The listed workspaces already named what a quick create would call this project.
*
* Names compare by Unicode collation, ignoring case but not accents: `Straße` matches
* `STRASSE`, `ΟΔΟΣ` matches `οδος` (final sigma) and `finance` matches `FINANCE` (ligature),
* all through collation, while `ı` and `i` or `café` and `cafe` stay different.
* Characters collation ignores, such as zero-width and bidi controls, do not make a name
* different. Ownership counts only when both the owner and the caller are known. */
export function findNamesakes<T extends { name: string; ownerId?: number }>(
list: readonly T[],
proposedName: string,
userId: number | undefined,
): Namesakes<T> {
const target = comparableName(proposedName)
if (!target) return { all: [], own: undefined }
const all = list.filter((workspace) => NAME_COLLATOR.compare(comparableName(workspace.name), target) === 0)
const own = userId === undefined ? undefined : all.find((workspace) => workspace.ownerId === userId)
return { all, own }
}

/** The hint a picker shows on a namesake's row, or undefined for any other row. */
export function namesakeHint<T extends { name: string; ownerId?: number }>(
workspace: T,
namesakes: Namesakes<T>,
userId: number | undefined,
): string | undefined {
if (!namesakes.all.includes(workspace)) return undefined
const someoneElses = userId !== undefined && workspace.ownerId !== undefined && workspace.ownerId !== userId
return someoneElses ? "same name, owned by someone else" : "same name as this project"
}

/** Where a link picker opens: the current link, else the caller's own namesake, else create. */
export function linkPickerOpensOn<T extends { id: number; name: string; ownerId?: number }>(
currentId: number | undefined,
namesakes: Namesakes<T>,
): number | "create" {
return currentId ?? namesakes.own?.id ?? "create"
}

/** Whether a choice in a link picker or the setup dialog must confirm first: both create
* paths start from the project's name, so either one would make a second namesake. */
export function confirmsNamesake(
choice: "create" | "browser" | "workspace",
namesakes: Namesakes<unknown>,
): boolean {
return choice !== "workspace" && namesakes.all.length > 0
}
Loading
Loading