diff --git a/apps/contract/src/contract.ts b/apps/contract/src/contract.ts index d68560ef..a43d6ff0 100644 --- a/apps/contract/src/contract.ts +++ b/apps/contract/src/contract.ts @@ -47,7 +47,7 @@ export const SidebarSession = Schema.Struct({ /** Its main thread. */ threadId: Schema.String, spaceKind: SpaceInfo.fields.kind, - /** Hex; null is the default. */ + /** Its main thread's color, else its own, as All Threads shows it; hex, null is the default. */ color: Schema.NullOr(Schema.String), /** The main thread's title, or what an untitled one is called. */ title: Schema.String, @@ -77,11 +77,27 @@ export const SidebarProject = Schema.Struct({ }); export type SidebarProject = typeof SidebarProject.Type; +/** One of a project's directories: its main checkout, a worktree, a copy or a plain folder. */ +export const ProjectSpace = Schema.Struct({ + id: Schema.String, + location: Schema.String, + kind: SpaceInfo.fields.kind, + /** The sandbox it is in; null for the host. */ + env: Schema.NullOr(Schema.String), + status: SpaceInfo.fields.status, +}); +export type ProjectSpace = typeof ProjectSpace.Type; + /** A host folder operation that failed: unreadable, not a directory, taken, or git/gh refused. */ export class HostError extends Schema.TaggedError()("Server.HostError", { message: Schema.String, }) {} +/** A project operation that failed, such as renaming one the server no longer has. */ +export class ProjectError extends Schema.TaggedError()("Server.ProjectError", { + message: Schema.String, +}) {} + export { Clone, ClonePhase, CloneStage, Folder, Repository }; /** The models could not be listed: a settings file or the model catalog, at `path`, is broken. */ @@ -439,6 +455,11 @@ export const Api = RpcGroup.make( * An organization's active projects, newest activity first, each with its sessions that are not * deleted: on the default machine only, or on every machine. Read from app.db alone. */ + /** A project's directories, its main checkout first; read from app.db alone. */ + Rpc.make("projects.spaces", { + payload: { machineId: Schema.String, projectId: Schema.String }, + success: Schema.Array(ProjectSpace), + }), Rpc.make("sidebar.list", { payload: { organizationId: Schema.String, machines: Schema.Literals(["default", "all"]) }, success: Schema.Array(SidebarProject), @@ -477,6 +498,18 @@ export const Api = RpcGroup.make( success: Schema.Struct({ path: Schema.String, commitError: Schema.optionalKey(Schema.String) }), error: Schema.Union([CodeworkUnavailable, HostError]), }), + /** The `origin` URL of a project's main checkout, credentials taken out; null when it has none. */ + Rpc.make("projects.remote", { + payload: { projectId: Schema.String }, + success: Schema.Struct({ url: Schema.NullOr(Schema.String) }), + error: Schema.Union([CodeworkUnavailable, HostError, ProjectError]), + }), + /** Renames a project on the CodeWork server; the sidebar follows once the change syncs back. */ + Rpc.make("projects.rename", { + payload: { projectId: Schema.String, name: Schema.Trimmed.check(Schema.isNonEmpty()) }, + success: Schema.Struct({ id: Schema.String, name: Schema.String }), + error: Schema.Union([CodeworkUnavailable, HostError, ProjectError]), + }), /** An existing folder as an absolute path; a missing one is created. */ Rpc.make("host.add", { payload: { path: Schema.String }, diff --git a/apps/server/src/handlers.ts b/apps/server/src/handlers.ts index 70108f90..6e372990 100644 --- a/apps/server/src/handlers.ts +++ b/apps/server/src/handlers.ts @@ -2,7 +2,14 @@ import { Effect, Stream } from "effect"; import { SqlClient } from "effect/sql"; import { Cli } from "./cli.ts"; import { Codework } from "./codework.ts"; -import { type CliAction, Contract, ModelsError, type ServerAdd, type ServerUpdate } from "@codeworksh/contract"; +import { + type CliAction, + Contract, + ModelsError, + ProjectError, + type ServerAdd, + type ServerUpdate, +} from "@codeworksh/contract"; import { Desk } from "./desk.ts"; import { Event } from "./event.ts"; import { Health } from "./health.ts"; @@ -45,6 +52,7 @@ export const layer = Contract.Api.toLayer( ...tracking, "sidebar.list": (input) => Reads.sidebar(input).pipe(Effect.provideService(SqlClient.SqlClient, sql)), + "projects.spaces": (input) => Reads.spaces(input).pipe(Effect.provideService(SqlClient.SqlClient, sql)), "threads.list": (input) => Reads.threads(input).pipe(Effect.provideService(SqlClient.SqlClient, sql)), "threads.line": (input) => Reads.line(input).pipe(Effect.provideService(SqlClient.SqlClient, sql)), @@ -58,6 +66,24 @@ export const layer = Contract.Api.toLayer( "host.create": Effect.fn("host.create")(function* ({ name }) { return yield* callHost((client) => client("host.create", { name }), { timeout: "60 seconds" }); }), + "projects.remote": Effect.fn("projects.remote")(function* ({ projectId }) { + return yield* callHost((client) => + client("project.remote", { projectId }).pipe( + Effect.catchTag("ProjectNotFoundError", () => + Effect.fail(new ProjectError({ message: "This project is no longer on its server." })), + ), + ), + ); + }), + "projects.rename": Effect.fn("projects.rename")(function* ({ projectId, name }) { + return yield* callHost((client) => + client("project.rename", { projectId, name }).pipe( + Effect.catchTag("ProjectNotFoundError", () => + Effect.fail(new ProjectError({ message: "This project is no longer on its server." })), + ), + ), + ); + }), "host.add": Effect.fn("host.add")(function* ({ path }) { return yield* callHost((client) => client("host.add", { path })); }), diff --git a/apps/server/src/reads.ts b/apps/server/src/reads.ts index e8043823..57323e5d 100644 --- a/apps/server/src/reads.ts +++ b/apps/server/src/reads.ts @@ -9,6 +9,7 @@ import { type LineRequest, LineThread, type Priority, + type ProjectSpace, type SidebarProject, type SidebarSession, SyncError, @@ -80,7 +81,8 @@ export const sidebar = Effect.fn("Reads.sidebar")(function* ({ createdAt: number; updatedAt: number; }>` - SELECT session.id, session.machine_id, space.project_id, space.kind AS space_kind, session.color, + SELECT session.id, session.machine_id, space.project_id, space.kind AS space_kind, + COALESCE(thread.color, session.color) AS color, session.dir, session.hostdir, thread.id AS thread_id, thread.title, thread.model_provider, thread.model_id, thread.run_state, thread.status, thread.priority, thread.created_at, (SELECT MAX(activity.updated_at) FROM thread AS activity @@ -427,4 +429,20 @@ export const line = Effect.fn("Reads.line")(function* (request: LineRequest) { }).pipe(Effect.orDie); }); +/** A project's directories, its main checkout first, then by folder. */ +export const spaces = Effect.fn("Reads.spaces")(function* ({ + machineId, + projectId, +}: { + readonly machineId: string; + readonly projectId: string; +}) { + const sql = yield* SqlClient.SqlClient; + return yield* sql` + SELECT id, location, kind, env, status FROM space + WHERE machine_id = ${machineId} AND project_id = ${projectId} + ORDER BY kind <> 'primary', location + `; +}, Effect.orDie); + export * as Reads from "./reads.ts"; diff --git a/apps/web/src/components/display.tsx b/apps/web/src/components/display.tsx index 143a4e62..31de5d95 100644 --- a/apps/web/src/components/display.tsx +++ b/apps/web/src/components/display.tsx @@ -29,7 +29,7 @@ export function DisplayMenu({ - + {children} diff --git a/apps/web/src/components/menu.tsx b/apps/web/src/components/menu.tsx index a9b4c646..fc219968 100644 --- a/apps/web/src/components/menu.tsx +++ b/apps/web/src/components/menu.tsx @@ -25,12 +25,14 @@ export function MenuCheck() { /** * One choice of a menu: its mark and name, opening its picks beside it with the current one checked. - * `after` holds actions rather than picks, such as clearing it, below a separator. + * `current` names the pick on the row itself, before the chevron. `after` holds actions rather than + * picks, such as clearing it, below a separator. */ export function MenuChoice({ label, mark, value, + current, options, onChange, after, @@ -38,15 +40,22 @@ export function MenuChoice({ readonly label: string; readonly mark: ReactNode; readonly value: A | undefined; + readonly current?: string | undefined; readonly options: ReadonlyArray<{ readonly value: A; readonly label: string; readonly mark: ReactNode }>; readonly onChange: (value: A) => void; readonly after?: ReactNode; }) { return ( - svg:last-child]:size-3.5`}> - {mark} + svg:last-child]:size-3.5 ${current === undefined ? "" : "[&>svg:last-child]:ml-0"}`} + > + {/* One width for every mark, so a dot's label lines up with an icon's. */} + {mark} {label} + {current !== undefined && ( + {current} + )} {/* Shifted up by its padding and border, so its first row lines up with the trigger. */} diff --git a/apps/web/src/components/ui/dropdown-menu.tsx b/apps/web/src/components/ui/dropdown-menu.tsx index 5bd29baa..a336fb00 100644 --- a/apps/web/src/components/ui/dropdown-menu.tsx +++ b/apps/web/src/components/ui/dropdown-menu.tsx @@ -15,6 +15,8 @@ function DropdownMenuTrigger({ ...props }: React.ComponentProps; } +// A menu comes out of its trigger: a short slide away from it as it fades in, no growth. It leaves +// by fading alone, quicker than it came. function DropdownMenuContent({ className, sideOffset = 4, @@ -26,7 +28,7 @@ function DropdownMenuContent({ data-slot="dropdown-menu-content" sideOffset={sideOffset} className={cn( - "z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-[12px] border bg-popover p-1 text-[13px] text-popover-foreground shadow-(--cw-menu-shadow) [-webkit-app-region:no-drag] data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:duration-100 data-[state=closed]:ease-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:duration-150 data-[state=open]:ease-out data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95", + "z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-[12px] border bg-popover p-1 text-[13px] text-popover-foreground shadow-(--cw-menu-shadow) [-webkit-app-region:no-drag] data-[side=bottom]:slide-in-from-top-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-[state=closed]:animate-out data-[state=closed]:duration-100 data-[state=closed]:ease-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:duration-150 data-[state=open]:ease-(--ease-out) data-[state=open]:fade-in-0 motion-reduce:animate-none", className, )} {...props} @@ -179,7 +181,8 @@ function DropdownMenuSubTrigger({ ); } -// Submenus open as an OS menu's do, at once: they are browsed by hover many times in a row. +// A submenu slides out of its row toward the side it opens on, shorter than a menu: rows are browsed +// by hover many times in a row. It closes at once, so moving to the next row never shows two. function DropdownMenuSubContent({ className, ...props @@ -188,7 +191,7 @@ function DropdownMenuSubContent({ instance.id === focusedId)?.address; + const workspace = useWorkspaces().find((candidate) => candidate.id === workspaceId); + const focusedId = workspace === undefined ? undefined : focusedOn.get(workspace.id); + const current = threadRef(instances.find((instance) => instance.id === focusedId)?.address)?.sessionId; + const shown = new Set( + workspace === undefined + ? [] + : instances.flatMap((instance) => + (instance.tags & workspaceTag(workspace)) === 0 ? [] : (threadRef(instance.address)?.sessionId ?? []), + ), + ); // A new session starts on the start page, in the current session's project when one is in front. const startNew = () => { const project = AsyncResult.isSuccess(result) - ? result.value.find((candidate) => candidate.sessions.some(({ id }) => sessionAddress(id) === current)) + ? result.value.find((candidate) => candidate.sessions.some(({ id }) => id === current)) : undefined; if (project !== undefined) setPlace({ kind: "folder", path: project.path }); void navigate({ to: "/" }); @@ -200,6 +209,7 @@ export function SessionsSidebar() { label="Grouping" mark={} value={display.group} + current={GROUPS.find(({ value }) => value === display.group)?.label} options={marked(GROUPS)} onChange={(group) => changeDisplay({ group })} /> @@ -207,6 +217,7 @@ export function SessionsSidebar() { label="Ordering" mark={} value={display.order} + current={ORDERS.find(({ value }) => value === display.order)?.label} options={marked(ORDERS)} onChange={(order) => changeDisplay({ order })} /> @@ -218,6 +229,7 @@ export function SessionsSidebar() { group={group} expanded={needle !== "" || !collapsed.has(group.key)} current={current} + shown={shown} onToggle={() => toggle(group.key)} onStartNew={group.kind === "project" ? () => startIn(group.project.path) : undefined} onOpen={open} @@ -248,27 +260,31 @@ function GroupSection({ group, expanded, current, + shown, onToggle, onStartNew, onOpen, }: { readonly group: Group; readonly expanded: boolean; + /** The session id in front on the workspace on screen. */ readonly current: string | undefined; + /** Every session id the workspace on screen shows. */ + readonly shown: ReadonlySet; readonly onToggle: () => void; /** A project's new thread; other groups have none. */ readonly onStartNew: (() => void) | undefined; readonly onOpen: (sessionId: string, projectName: string, event: MouseEvent) => void; }) { const label = groupLabel(group); - const open = expanded ? undefined : group.entries.find(({ session }) => sessionAddress(session.id) === current); + const open = expanded ? undefined : group.entries.find(({ session }) => session.id === current); const row = ({ session, project }: Entry) => ( ); @@ -328,14 +344,15 @@ const SessionRow = memo(function SessionRow({ session, projectName, machineName, - current, + state, onOpen, }: { /** Kept across reads while it does not change, with the project's parts as plain values, so the row renders only for its own changes. */ readonly session: SidebarSession; readonly projectName: string; readonly machineName: string; - readonly current: boolean; + /** In front on the workspace on screen, or shown there in another frame. */ + readonly state: "current" | "shown" | undefined; readonly onOpen: (sessionId: string, projectName: string, event: MouseEvent) => void; }) { // The main thread's key is its session's id. @@ -345,12 +362,24 @@ const SessionRow = memo(function SessionRow({ + +
{ + if (event.key !== "Escape" || event.defaultPrevented) return; + event.preventDefault(); + onBack(); + }} + className="min-h-0 flex-1 overflow-y-auto outline-none" + > +
+ {project !== undefined && } +
+
+ + ); +} + +interface View { + readonly project: SidebarProject; + readonly main: ProjectSpace | undefined; + /** Null when the project has no remote, or it could not be read. */ + readonly url: string | null; +} + +/** + * The page for `project`, drawn once its directories and remote are both in. Until then the page last + * drawn stays, whole: switching threads never shows one project's banner over a section still loading. + */ +function Page({ project }: { readonly project: SidebarProject }) { + const spaces = useAtomValue(spacesAtom(`${project.machine.id}/${project.id}`)); + const remote = useAtomValue(remoteOf(project.id)); + const settled = (result: AsyncResult.AsyncResult) => + AsyncResult.isSuccess(result) || AsyncResult.isFailure(result); + const main = AsyncResult.isSuccess(spaces) ? spaces.value.find((space) => space.kind === "primary") : undefined; + const url = AsyncResult.isSuccess(remote) ? remote.value.url : null; + const ready = settled(spaces) && settled(remote); + const [view, setView] = useState(); + if (ready && (view?.project !== project || view.main !== main || view.url !== url)) setView({ project, main, url }); + if (view === undefined) return null; + return ( + <> + {/* Keyed, so a name typed for one project never carries into the next. */} + + {view.main !== undefined && } + + ); +} + +/** The project as a tile, its name as the page's title and its folder. The name is a field: type, then Enter or leave it. */ +function Banner({ project }: { readonly project: SidebarProject }) { + const rename = useAtomSet(renameProject, { mode: "promise" }); + // What is typed, until it is saved or put back. + const [draft, setDraft] = useState(); + // The saved name, shown until the rename syncs back to the list. + const [pending, setPending] = useState(); + if (pending !== undefined && project.name === pending) setPending(undefined); + const name = pending ?? project.name; + const commit = (value: string) => { + const next = value.trim(); + setDraft(undefined); + if (next === "" || next === name) return; + setPending(next); + void rename({ payload: { projectId: project.id, name: next } }).catch((error: unknown) => { + setPending(undefined); + failed(error); + }); + }; + return ( +
+ + + +
+ setDraft(event.target.value)} + onBlur={(event) => commit(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Enter") event.currentTarget.blur(); + if (event.key === "Escape") { + // Puts the saved name back; the settings stay open. + event.preventDefault(); + setDraft(undefined); + event.currentTarget.value = name; + event.currentTarget.blur(); + } + }} + className="-mx-1.5 h-8 w-full min-w-0 truncate rounded-md bg-transparent px-1.5 text-[22px]/8 font-medium tracking-[-0.01em] outline-none transition-colors duration-150 ease-out hover:bg-ink/4 focus:bg-ink/4" + /> + {/* From its end: the last parts tell projects apart, not the home directory. */} + + {project.path} + +
+
+ ); +} + +/** A section heading as Linear sets one, a step quieter: medium weight, in line with its rows' text. */ +function Heading({ children }: { readonly children: string }) { + return

{children}

; +} + +/** The project's repository: the URL it was cloned from, then its main checkout. */ +function Repository({ main, url }: { readonly main: ProjectSpace; readonly url: string | null }) { + return ( +
+ Repositories +
+
+
+ Remote URL + Where this repository was cloned from +
+ {url === null ? ( + No remote + ) : ( + // Text to read and copy, not a field: nothing here suggests it can be changed. +
+ + {url} + + +
+ )} +
+
+ +
+ {nameOf(main.location)} + {/* From its end: the last parts tell checkouts apart. */} + + {main.location} + +
+ Main +
+
+
+ ); +} + +/** Copies `text`, then shows a check for a moment so the copy is seen to have happened. */ +function CopyButton({ text }: { readonly text: string }) { + const [copied, setCopied] = useState(false); + useEffect(() => { + if (!copied) return; + const timer = setTimeout(() => setCopied(false), 1500); + return () => clearTimeout(timer); + }, [copied]); + return ( + + ); +} + +const nameOf = (location: string) => location.split("/").filter(Boolean).at(-1) ?? location; diff --git a/apps/web/src/widgets/session/view.tsx b/apps/web/src/widgets/session/view.tsx index 27fa790f..27c87b91 100644 --- a/apps/web/src/widgets/session/view.tsx +++ b/apps/web/src/widgets/session/view.tsx @@ -1,5 +1,5 @@ import { ChevronRight, CircleDashed, Folder, X } from "@/components/vuesax"; -import { RegistryContext, useAtomSet, useAtomValue } from "@effect/atom-react"; +import { RegistryContext, useAtomMount, useAtomSet, useAtomValue } from "@effect/atom-react"; import { type HistoryEntry, type LiveDelta, Part, type QueuedPrompt, type ToolCall } from "@codeworksh/widget/codework"; import { useBus, useEvents, useFrame, useShell, type WidgetProps } from "@codeworksh/widget/view"; import { Option, Schema } from "effect"; @@ -14,8 +14,10 @@ import { threadAddress, threadRef } from "./address"; import { SessionComposer } from "./composer"; import { useFollow } from "./follow"; import { Markdown } from "./markdown"; +import { menuThreads, useSettings } from "./menu"; import { Message } from "./message"; import { Queue } from "./queue"; +import { ProjectSettings } from "./settings"; import { type Handoff, handoffs } from "./handoff"; import { type Display, displayLabel, usePendingPrompts, useRunDisplay, useSent } from "./display"; import { useNowSecond } from "../../hooks/now"; @@ -45,8 +47,18 @@ export function Session({ address, instanceId }: WidgetProps) { // The thread this frame showed before, so a switch can keep it in view while the next one loads. const [shown, setShown] = useState<{ readonly key: string; readonly previous?: string }>({ key }); if (shown.key !== key) setShown({ key, previous: shown.key }); + useAtomMount(menuThreads); + const [settings, setSettings] = useSettings(instanceId); if (thread === undefined) return

Not a session: {address}

; - return ; + // The thread stays mounted under its project's settings, so it comes back as it was left. + return ( + <> + + {settings && setSettings(false)} />} + + ); } type State = @@ -493,10 +505,17 @@ function Conversation({ // Named from the sessions list at once, so the frame never shows its default title while loading. const listed = useListedSession(thread.sessionId); const title = state.status === "ready" ? state.info.title : listed?.title; - const project = state.status === "ready" ? state.info.project : listed?.project; + // The list follows a rename; the loaded thread keeps the name it was opened with. + const project = listed?.project ?? (state.status === "ready" ? state.info.project : undefined); const projectName = project?.name; + const [settings] = useSettings(instanceId); useEffect(() => { + if (settings) { + setTitle("Settings", projectName); + setTab(projectName === undefined ? "Settings" : `${projectName} / Settings`); + return; + } if (title === undefined) return; // The project leads, so sessions from different projects tell apart at a glance. setTitle(title, projectName); @@ -505,7 +524,7 @@ function Conversation({ if (projectName !== undefined) setIcon(); // While focused, the workspace's tab reads as this session: its project, then its title. setTab(projectName === undefined ? title : `${projectName} / ${title}`); - }, [title, projectName, setTitle, setIcon, setTab]); + }, [settings, title, projectName, setTitle, setIcon, setTab]); // Models that can't be listed first: a state that clears itself, so it can't be dismissed. Then a // refused send, else why the last run failed; dismissing hides that one notice only. diff --git a/apps/web/src/widgets/threads/toolbar.tsx b/apps/web/src/widgets/threads/toolbar.tsx index 482aaa0c..5d8c3799 100644 --- a/apps/web/src/widgets/threads/toolbar.tsx +++ b/apps/web/src/widgets/threads/toolbar.tsx @@ -143,6 +143,7 @@ export function Toolbar({ label="Grouping" mark={} value={options.group} + current={GROUPS.find(({ value }) => value === options.group)?.label} options={marked(GROUPS)} onChange={(group) => onChange({ group })} /> @@ -150,6 +151,7 @@ export function Toolbar({ label="Ordering" mark={} value={options.order} + current={ORDERS.find(({ value }) => value === options.order)?.label} options={marked(ORDERS)} onChange={(order) => onChange({ order })} /> diff --git a/apps/web/src/wm/desktop.tsx b/apps/web/src/wm/desktop.tsx index 9d0365ba..44fa259b 100644 --- a/apps/web/src/wm/desktop.tsx +++ b/apps/web/src/wm/desktop.tsx @@ -20,6 +20,8 @@ import { useElementSize } from "./size"; import { type Frame as FrameApi, FrameContext, HostContext, type Shell, ShellContext } from "@codeworksh/widget/view"; import { makeHost } from "../kernel/host"; import { builtins, registry } from "../widgets"; +import { session } from "../widgets/session"; +import { ThreadMenu } from "../widgets/session/menu"; import { type Chrome, chromeAtom, chromeOf, focusedAtom, tabOwnerAtom } from "./chrome"; import { Divider } from "./divider"; import { Frame, FRAME_HEADER_HEIGHT } from "./frame"; @@ -464,6 +466,11 @@ export function Desktop({ workspace }: DesktopProps) { if (destination !== undefined) move(widget.id, destination); }} onMoveNew={workspaces.length < MAX_WORKSPACES ? () => void moveNew(widget.id) : undefined} + menu={ + instance.kind === session.kind ? ( + + ) : undefined + } onClose={() => close(widget.id)} onDragStart={() => setDrag({ widgetId: widget.id, deltaX: 0, deltaY: 0, target: null })} onDragMove={({ clientX, clientY, deltaX, deltaY }) => diff --git a/apps/web/src/wm/frame.tsx b/apps/web/src/wm/frame.tsx index 1fa374b8..beac683d 100644 --- a/apps/web/src/wm/frame.tsx +++ b/apps/web/src/wm/frame.tsx @@ -5,6 +5,7 @@ import { GripVertical, Maximize2, Minimize2, + MoreHorizontal, Pin, PinOff, X, @@ -19,12 +20,15 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import type { FrameIcon } from "@codeworksh/widget/view"; +import { MENU_ROW } from "@/components/menu"; import { shortcutLabel } from "../shell/switcher"; import { Title } from "./title"; @@ -64,6 +68,8 @@ interface FrameProps { readonly onMove: (workspaceId: string) => void; /** Moves the frame to a new workspace; unset while all workspaces are in use. */ readonly onMoveNew: (() => void) | undefined; + /** The widget's own rows in the frame menu, below Move. */ + readonly menu?: ReactNode; readonly onClose: () => void; readonly onDragStart: () => void; readonly onDragMove: (movement: DragMovement) => void; @@ -133,7 +139,9 @@ export function Frame(props: FrameProps) { active={false} onClick={props.onMaximize} /> - + + {props.menu} + @@ -146,38 +154,54 @@ export function Frame(props: FrameProps) { ); } -function Move({ +/** The frame menu: Move, then the widget's own rows. */ +function More({ destinations, onMove, onMoveNew, + children, }: { readonly destinations: readonly Destination[]; readonly onMove: (workspaceId: string) => void; readonly onMoveNew: (() => void) | undefined; + readonly children?: ReactNode; }) { return ( - + - - Move - {destinations.map(({ id, name, position }) => ( - onMove(id)}> + + + svg:last-child]:size-3.5`}> - {name} - {shortcutLabel(position)} - - ))} - {destinations.length > 0 && } - onMoveNew?.()}> - - New Workspace - + Move +
+ {/* Shifted up by its padding and border, so its first row lines up with the trigger. */} + + {destinations.map(({ id, name, position }) => ( + onMove(id)}> + + {name} + {shortcutLabel(position)} + + ))} + {destinations.length > 0 && } + onMoveNew?.()} + > + + New Tab + + +
+ {children}
); diff --git a/apps/widget/src/codework.ts b/apps/widget/src/codework.ts index bcaf4776..60456cd8 100644 --- a/apps/widget/src/codework.ts +++ b/apps/widget/src/codework.ts @@ -373,6 +373,10 @@ export const RuntimeConfig = Schema.Struct({ export type RuntimeConfig = typeof RuntimeConfig.Type; /** A host folder operation that failed: unreadable, not a directory, taken, or git/gh refused. */ +export class ProjectNotFoundError extends Schema.TaggedError()("ProjectNotFoundError", { + projectId: Schema.String, +}) {} + export class HostError extends Schema.TaggedError()("Server.HostError", { message: Schema.String, }) {} @@ -522,6 +526,18 @@ export const Api = RpcGroup.make( success: ThreadInfo, error: CreateError, }), + /** The `origin` URL of a project's main checkout on the host, credentials taken out; null when there is none. */ + Rpc.make("project.remote", { + payload: { projectId: Schema.String }, + success: Schema.Struct({ url: Schema.NullOr(Schema.String) }), + error: ProjectNotFoundError, + }), + /** Renames a project. Trimmed, non-empty. */ + Rpc.make("project.rename", { + payload: { projectId: Schema.String, name: Schema.String }, + success: Schema.Struct({ id: Schema.String, name: Schema.String }), + error: ProjectNotFoundError, + }), /** The models a session anchored at `hostDir` can use; without it, only the user's settings apply. */ Rpc.make("model.list", { payload: { hostDir: Schema.optionalKey(Schema.String) }, diff --git a/packages/codework/src/server/contract.ts b/packages/codework/src/server/contract.ts index c3db2716..3078147d 100644 --- a/packages/codework/src/server/contract.ts +++ b/packages/codework/src/server/contract.ts @@ -493,6 +493,12 @@ export const Api = RpcGroup.make( /** `HostError` when `hostDir` is a clone still running, failed or cancelled. */ error: Schema.Union([SessionErrors, SandboxErrors, LocationErrors, HostError]), }), + /** The `origin` URL of a project's main checkout on the host, credentials taken out; null when there is none. */ + Rpc.make("project.remote", { + payload: { projectId: Schema.String }, + success: Schema.Struct({ url: Schema.NullOr(Schema.String) }), + error: Project.ProjectNotFoundError, + }), Rpc.make("project.rename", { payload: { projectId: Schema.String, name: ProjectName }, success: Schema.Struct({ id: Schema.String, name: Schema.String }), diff --git a/packages/codework/src/server/handlers.ts b/packages/codework/src/server/handlers.ts index 42777b52..abaee83c 100644 --- a/packages/codework/src/server/handlers.ts +++ b/packages/codework/src/server/handlers.ts @@ -308,6 +308,11 @@ export const layer = Contract.Api.toLayer( }, }; }), + "project.remote": Effect.fnUntraced(function* ({ projectId }) { + const checkout = yield* Project.mainCheckout(projectId); + const url = checkout === undefined ? undefined : yield* host.remote(checkout); + return { url: url ?? null }; + }), "project.rename": Effect.fnUntraced(function* ({ projectId, name }) { const renamed = yield* Project.rename({ projectId, name }); return { id: renamed.id, name: renamed.name }; diff --git a/packages/codework/src/server/host.ts b/packages/codework/src/server/host.ts index 3cf72d6d..3fcf22b8 100644 --- a/packages/codework/src/server/host.ts +++ b/packages/codework/src/server/host.ts @@ -26,6 +26,8 @@ interface Interface { ) => Effect.Effect<{ readonly path: string; readonly commitError?: string }, Contract.HostError>; readonly status: () => Effect.Effect; readonly lookup: (repository: string) => Effect.Effect; + /** The `origin` URL of the checkout at `directory`, with any credentials taken out; none without one. */ + readonly remote: (directory: string) => Effect.Effect; } export class Service extends Context.Service()("@codeworksh/cli/server/host/Service") {} @@ -113,6 +115,19 @@ export const parseRepository = (selector: string): Selector | undefined => { return parts.length === 3 && host !== undefined && isGitHubHost(host) ? named(host, second, third) : undefined; }; +/** + * A remote URL safe to show: an http(s) URL loses its user and password, which may be a token + * (`https://x-access-token:...@github.com/...`). SSH and local forms carry none and pass as they are. + */ +export const shownRemote = (url: string) => { + if (!/^https?:\/\//i.test(url)) return url; + const parsed = URL.parse(url); + if (parsed === null) return url; + parsed.username = ""; + parsed.password = ""; + return parsed.toString(); +}; + const decodeRepository = Schema.decodeUnknownEffect( Schema.fromJsonString(Schema.Struct({ full_name: Schema.String, html_url: Schema.String, ssh_url: Schema.String })), ); @@ -280,7 +295,12 @@ const make = Effect.gen(function* () { return { nameWithOwner: found.full_name, url: found.html_url, sshUrl: found.ssh_url }; }); - return Service.of({ info, browse, add, create, status, lookup }); + const remote = Effect.fn("Host.remote")(function* (directory: string) { + const url = yield* git.remote(directory); + return url === undefined ? undefined : shownRemote(url); + }); + + return Service.of({ info, browse, add, create, status, lookup, remote }); }); // Git runs on the host; every call names its own directory, so the mount root is never read. diff --git a/packages/codework/test/__artifacts__/server.project-name.json b/packages/codework/test/__artifacts__/server.project-name.json index e9adfa52..58996afb 100644 --- a/packages/codework/test/__artifacts__/server.project-name.json +++ b/packages/codework/test/__artifacts__/server.project-name.json @@ -42,5 +42,12 @@ "rename": "rejected" } }, - "sessionsCreatedByInvalid": 0 + "sessionsCreatedByInvalid": 0, + "remote": { + "repo": "https://github.com/org/named-upstream", + "tokened": "https://github.com/org/tokened", + "folder": null, + "memory": null, + "missing": "ProjectNotFoundError" + } } diff --git a/packages/codework/test/server.test.ts b/packages/codework/test/server.test.ts index 6d96ac7a..69427d09 100644 --- a/packages/codework/test/server.test.ts +++ b/packages/codework/test/server.test.ts @@ -500,6 +500,26 @@ describe("server", () => { const after = (yield* rpc["thread.list"]({})).length; expect(after).toBe(before); + // 7. The origin URL of the main checkout on the host: credentials never leave the server. + const tokened = yield* Effect.promise(async () => { + const path = await repo(dir("tokened"), "tokened"); + await git(path, "remote", "set-url", "origin", "https://x-access-token:secret@github.com/org/tokened"); + return path; + }); + const tokenedSession = yield* rpc["session.create"]({ directory: tokened }); + const remoteOf = (sessionId: Session.SessionSchema.ID) => + projectOf(sessionId).pipe( + Effect.flatMap((project) => rpc["project.remote"]({ projectId: project?.id ?? "" })), + Effect.map(({ url }) => url), + ); + const remote = { + repo: yield* remoteOf(repoNamed.session.id), + tokened: yield* remoteOf(tokenedSession.session.id), + folder: yield* remoteOf(folderNamed.session.id), + memory: yield* remoteOf(memoryNamed.session.id), + missing: (yield* rpc["project.remote"]({ projectId: "prj_missing" }).pipe(Effect.flip))._tag, + }; + const artifact = { memory: { named: yield* nameOf(memoryNamed.session.id), unnamed: yield* nameOf(memoryUnnamed.session.id) }, folder: { named: yield* nameOf(folderNamed.session.id), unnamed: yield* nameOf(folderUnnamed.session.id) }, @@ -517,6 +537,7 @@ describe("server", () => { }, invalid, sessionsCreatedByInvalid: after - before, + remote, }; expect(artifact.memory).toEqual({ named: "In Memory", unnamed: "" }); expect(artifact.folder).toEqual({ named: "Plain", unnamed: "plain-unnamed" }); @@ -525,6 +546,13 @@ describe("server", () => { expect(artifact.rename).toMatchObject({ first: "C", second: "C", result: { sameId: true, name: "C" } }); for (const outcome of Object.values(invalid)) expect(outcome).toEqual({ create: "rejected", rename: "rejected" }); + expect(artifact.remote).toEqual({ + repo: "https://github.com/org/named-upstream", + tokened: "https://github.com/org/tokened", + folder: null, + memory: null, + missing: "ProjectNotFoundError", + }); return artifact; }) .pipe(Effect.scoped, Effect.provide(layer()), Effect.runPromise) diff --git a/packages/harness/src/project/project.ts b/packages/harness/src/project/project.ts index 689a7b76..0b36417b 100644 --- a/packages/harness/src/project/project.ts +++ b/packages/harness/src/project/project.ts @@ -260,6 +260,21 @@ export const rename = Effect.fn("Project.rename")(function* (input: { }); }); +/** + * A project's main checkout on the host, if it has an active one. Needs only the database; a + * checkout inside a sandbox has no host path to run git in. + */ +export const mainCheckout = Effect.fn("Project.mainCheckout")(function* (projectId: string) { + const sql = yield* SqlClient.SqlClient; + const found = yield* sql<{ id: string }>`SELECT id FROM project WHERE id = ${projectId}`.pipe(Effect.orDie); + if (found.length === 0) return yield* new ProjectNotFoundError({ projectId }); + const rows = yield* sql<{ location: string }>` + SELECT location FROM space + WHERE project_id = ${projectId} AND env IS NULL AND kind = 'primary' AND status = 'active' + `.pipe(Effect.orDie); + return rows[0]?.location; +}); + /** Takes an assembled sandbox — local or remote — not a local backend. */ export const layerWith = (sandbox: Sandbox.Sandbox) => layer.pipe(