Skip to content
Draft
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
35 changes: 34 additions & 1 deletion apps/contract/src/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<HostError>()("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<ProjectError>()("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. */
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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 },
Expand Down
28 changes: 27 additions & 1 deletion apps/server/src/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)),

Expand All @@ -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 }));
}),
Expand Down
20 changes: 19 additions & 1 deletion apps/server/src/reads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type LineRequest,
LineThread,
type Priority,
type ProjectSpace,
type SidebarProject,
type SidebarSession,
SyncError,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<ProjectSpace>`
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";
2 changes: 1 addition & 1 deletion apps/web/src/components/display.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export function DisplayMenu({
<SlidersHorizontal aria-hidden />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align={align} side={side} className="min-w-40">
<DropdownMenuContent align={align} side={side} className="min-w-44">
{children}
</DropdownMenuContent>
</DropdownMenu>
Expand Down
15 changes: 12 additions & 3 deletions apps/web/src/components/menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,28 +25,37 @@ 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<A extends string>({
label,
mark,
value,
current,
options,
onChange,
after,
}: {
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 (
<DropdownMenuSub>
<DropdownMenuSubTrigger className={`${MENU_ROW} [&>svg:last-child]:size-3.5`}>
{mark}
<DropdownMenuSubTrigger
className={`${MENU_ROW} [&>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. */}
<span className="grid size-[15px] shrink-0 place-items-center">{mark}</span>
{label}
{current !== undefined && (
<span className="ml-auto max-w-28 truncate pl-4 text-[12px] text-ink-muted">{current}</span>
)}
</DropdownMenuSubTrigger>
{/* Shifted up by its padding and border, so its first row lines up with the trigger. */}
<DropdownMenuSubContent alignOffset={-5} className="min-w-40">
Expand Down
9 changes: 6 additions & 3 deletions apps/web/src/components/ui/dropdown-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ function DropdownMenuTrigger({ ...props }: React.ComponentProps<typeof DropdownM
return <DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />;
}

// 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,
Expand All @@ -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}
Expand Down Expand Up @@ -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
Expand All @@ -188,7 +191,7 @@ function DropdownMenuSubContent({
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-[12px] border bg-popover p-1 text-[13px] text-popover-foreground shadow-(--cw-menu-shadow) [-webkit-app-region:no-drag]",
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-[12px] border bg-popover p-1 text-[13px] text-popover-foreground shadow-(--cw-menu-shadow) [-webkit-app-region:no-drag] data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[state=open]:animate-in data-[state=open]:duration-120 data-[state=open]:ease-(--ease-out) data-[state=open]:fade-in-0 motion-reduce:animate-none",
className,
)}
{...props}
Expand Down
53 changes: 41 additions & 12 deletions apps/web/src/landing/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,11 @@ import {
SidebarRail,
} from "@/components/ui/sidebar";
import { isMac, openMode } from "@codeworksh/widget/view";
import { sessionAddress } from "../widgets/session/address";
import { sessionAddress, threadRef } from "../widgets/session/address";
import { projectsAtom } from "../widgets/session/projects";
import { focusedAtom } from "../wm/chrome";
import { deskAtom } from "../wm/instances";
import { useWorkspaces, workspaceTag } from "../wm/workspaces";
import { useOpenElsewhere } from "../wm/open";
import { Ago } from "../widgets/time";
import { OpenProviderSettings } from "@/components/offline";
Expand Down Expand Up @@ -73,16 +74,24 @@ export function SessionsSidebar() {
const [place, setPlace] = useAtom(placeAtom);
const addProject = useAddProject();
const navigate = useNavigate();
// The session in front on the workspace on screen reads as current.
// The session in front on the workspace on screen reads as current; the others it shows, as open.
const { workspaceId } = useParams({ strict: false });
const { instances } = useAtomValue(deskAtom);
const focusedOn = useAtomValue(focusedAtom);
const focusedId = workspaceId === undefined ? undefined : focusedOn.get(workspaceId);
const current = instances.find((instance) => 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: "/" });
Expand Down Expand Up @@ -200,13 +209,15 @@ export function SessionsSidebar() {
label="Grouping"
mark={<LayoutGrid aria-hidden />}
value={display.group}
current={GROUPS.find(({ value }) => value === display.group)?.label}
options={marked(GROUPS)}
onChange={(group) => changeDisplay({ group })}
/>
<MenuChoice
label="Ordering"
mark={<ArrowUpDown aria-hidden />}
value={display.order}
current={ORDERS.find(({ value }) => value === display.order)?.label}
options={marked(ORDERS)}
onChange={(order) => changeDisplay({ order })}
/>
Expand All @@ -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}
Expand Down Expand Up @@ -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<string>;
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) => (
<SessionRow
key={session.id}
session={session}
projectName={project.name}
machineName={project.machine.name}
current={sessionAddress(session.id) === current}
state={session.id === current ? "current" : shown.has(session.id) ? "shown" : undefined}
onOpen={onOpen}
/>
);
Expand Down Expand Up @@ -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.
Expand All @@ -345,12 +362,24 @@ const SessionRow = memo(function SessionRow({
<button
type="button"
title={`${session.path} on ${machineName}\n${isMac ? "⌘" : "Ctrl"}-click opens it in a new frame\n⇧-click opens it in a new workspace`}
aria-current={current ? "true" : undefined}
className="group/row relative flex h-[26px] w-full min-w-0 items-center gap-2 rounded-md px-2 text-left text-ui text-ink-muted outline-none hover:bg-selection-subtle hover:text-ink focus-visible:ring-2 focus-visible:ring-ring aria-current:bg-selection-current aria-current:font-medium aria-current:text-ink"
aria-current={state === "current" ? "true" : undefined}
data-state={state}
// One weight in every state: a bolder current row would shift its title as focus moves.
className="group/row relative flex h-[26px] w-full min-w-0 items-center gap-2 rounded-md px-2 text-left text-ui text-ink-muted outline-none hover:bg-selection-subtle hover:text-ink focus-visible:ring-2 focus-visible:ring-ring data-[state=current]:bg-selection-current data-[state=current]:text-ink data-[state=shown]:text-ink"
onClick={(event) => onOpen(session.id, projectName, event)}
onAuxClick={(event) => onOpen(session.id, projectName, event)}
>
<span className="flex size-[15px] shrink-0 items-center justify-center text-ink-muted group-aria-current/row:text-ink">
{/* Its color washes in from the right edge, behind the time, so it shows through a run and takes no room. */}
{session.color !== null && (
<span
aria-hidden
className="pointer-events-none absolute inset-y-0 right-0 w-2/5 rounded-r-md"
style={{
backgroundImage: `linear-gradient(to left, color-mix(in srgb, ${session.color} 22%, transparent), transparent)`,
}}
/>
)}
<span className="flex size-[15px] shrink-0 items-center justify-center text-ink-muted group-data-[state]/row:text-ink">
<RunMark display={display} />
</span>
<span className="min-w-0 flex-1 truncate">{session.title}</span>
Expand Down
Loading