char-slop/berry

multipurpose self-modifiable agentic harness

git clone https://git.t4t.associates/char-slop/berry

Charlotte Somallow project reorderingb4fc6cf

main
11.9 KiB366 linesraw
1import { Signal } from "@char/aftercare";
2import {
3  type Project,
4  projectFor,
5  type ProjectIndex,
6  type ProjectInput,
7  type Workspace,
8  workspaceFor,
9} from "@berry/api/projects.ts";
10import { api } from "./client.ts";
11import { configurationField } from "./configuration.tsx";
12import { folderInput } from "./folders.tsx";
13import { Scope } from "./ui.ts";
14
15const workspaceLabel = (root: { environment: string; cwd: string }) =>
16  `${root.environment === "local" ? "" : `${root.environment} · `}${root.cwd || "home"}`;
17
18/** The sidebar's groups in order, `""` being no project; those never ordered follow by name. */
19export function sidebarOrder(projects: ProjectIndex, order: readonly string[]): string[] {
20  const named = Object.keys(projects).sort((a, b) =>
21    projects[a].name.localeCompare(projects[b].name)
22  );
23  return [...new Set([...order, ...named, ""])].filter((id) => id === "" || id in projects);
24}
25
26/** Move a sidebar group to `index`, saving the new order. */
27export async function moveGroup(
28  projects: ProjectIndex,
29  order: Signal<readonly string[]>,
30  id: string,
31  index: number,
32) {
33  const ids = sidebarOrder(projects, order.get()).filter((other) => other !== id);
34  ids.splice(index, 0, id);
35  await api("PUT", "/api/project-order", { ids });
36  order.set(ids);
37}
38
39export function projectPicker(
40  scope: Scope,
41  projects: Signal<ProjectIndex>,
42  choose: (workspace: Workspace | null) => void,
43) {
44  const select = <select aria-label="project" /> as HTMLSelectElement;
45  const workspace = <select aria-label="workspace" /> as HTMLSelectElement;
46  const workspaceField = (
47    <label class="field" hidden>
48      <span>workspace</span>
49      {workspace}
50    </label>
51  ) as HTMLLabelElement;
52  const element = (
53    <div class="project-picker">
54      <label class="field">
55        <span>project</span>
56        {select}
57      </label>
58      {workspaceField}
59    </div>
60  ) as HTMLElement;
61  const draw = (reset = false) => {
62    const previous = workspace.selectedOptions[0]?.dataset.key;
63    const selected = select.value;
64    const index = projects.get();
65    select.replaceChildren(
66      <option value="">no project</option>,
67      ...Object.entries(index)
68        .sort(([, a], [, b]) => a.name.localeCompare(b.name))
69        .map(([id, project]) => <option value={id}>{project.name}</option>),
70    );
71    select.value = index[selected] ? selected : "";
72    const project = index[select.value];
73    const key = (root: Workspace) => `${root.environment}\n${root.cwd}`;
74    workspace.replaceChildren(
75      ...(project?.roots ?? []).map((root, i) => (
76        <option value={String(i)} data-key={key(root)}>{workspaceLabel(root)}</option>
77      )),
78    );
79    const previousIndex = reset
80      ? -1
81      : project?.roots.findIndex((root) => key(root) === previous) ?? -1;
82    workspace.value = String(previousIndex >= 0 ? previousIndex : project?.defaultRoot ?? 0);
83    workspaceField.hidden = !project || project.roots.length < 2;
84    element.hidden = Object.keys(index).length === 0;
85  };
86  const pick = (id: string) => {
87    select.value = id;
88    draw(true);
89    const project = projects.get()[select.value];
90    choose(project?.roots[project.defaultRoot] ?? null);
91  };
92  select.addEventListener("change", () => pick(select.value));
93  workspace.addEventListener("change", () => {
94    const root = projects.get()[select.value]?.roots[Number(workspace.value)];
95    if (root) choose(root);
96  });
97  scope.add(projects.subscribe(() => draw()).unsubscribe);
98  draw();
99  return {
100    element,
101    pick,
102    /** Show the project, and its workspace, that a place chosen by hand is in, without choosing it again. */
103    show(place: { environment: string; cwd: string }) {
104      const id = projectFor(projects.get(), place) ?? "";
105      if (select.value !== id) {
106        select.value = id;
107        draw(true);
108      }
109      const project = projects.get()[id];
110      const root = project && workspaceFor(project, place);
111      if (root) workspace.value = String(project.roots.indexOf(root));
112    },
113  };
114}
115
116export function projectEditor(
117  scope: Scope,
118  machines: Signal<readonly string[]>,
119  projects: Signal<ProjectIndex>,
120  order = new Signal<readonly string[]>([]),
121) {
122  const title = <h2 id="project-editor-title" /> as HTMLElement;
123  const name = <input required maxLength={200} /> as HTMLInputElement;
124  const roots = <div class="project-roots" /> as HTMLElement;
125  const status = <p role="status" class="error" /> as HTMLElement;
126  const remove = (
127    <button type="button" class="link danger project-delete">
128      delete project
129    </button>
130  ) as HTMLButtonElement;
131  const move = <input type="checkbox" /> as HTMLInputElement;
132  const moveField = (
133    <label class="field" hidden>
134      <span>{move} move conversations from removed workspaces to the default workspace</span>
135    </label>
136  ) as HTMLLabelElement;
137  let original: Project | undefined;
138  let id: string | undefined;
139  let draft: ProjectInput = { name: "", roots: [], defaultRoot: 0 };
140  let fields = new Scope();
141  let pending = false;
142  const updateRemoval = () => {
143    moveField.hidden = !original?.roots.some((root) =>
144      !draft.roots.some((saved) => saved.environment === root.environment && saved.cwd === root.cwd)
145    );
146  };
147  const draw = () => {
148    fields.dispose();
149    fields = new Scope();
150    roots.replaceChildren(...draft.roots.map((root, i) => {
151      const environment = new Signal(root.environment);
152      const folder = folderInput({ scope: fields, environment, projects, base: () => null });
153      folder.input.value = root.cwd;
154      folder.input.setAttribute("aria-label", `folder of workspace ${i + 1}`);
155      folder.input.addEventListener("input", () => {
156        root.cwd = folder.input.value;
157        updateRemoval();
158      });
159      const machine = configurationField(
160        "environment",
161        { type: "string" },
162        root.environment,
163        (value) => {
164          root.environment = String(value);
165          root.cwd = folder.input.value = "";
166          environment.set(root.environment);
167          updateRemoval();
168        },
169        { scope: fields, choices: machines },
170      ).input;
171      machine.setAttribute("aria-label", `environment of workspace ${i + 1}`);
172      return (
173        <div class="project-root" role="group" aria-label={`workspace ${i + 1}`}>
174          <input
175            type="radio"
176            name="default-workspace"
177            title="default directory for new conversations"
178            aria-label={`start new conversations in workspace ${i + 1}`}
179            checked={draft.defaultRoot === i}
180            _onchange={() => draft.defaultRoot = i}
181          />
182          {machine}
183          {folder.element}
184          <button
185            type="button"
186            class="link danger"
187            title="remove workspace"
188            aria-label={`remove workspace ${i + 1}`}
189            disabled={draft.roots.length === 1}
190            _onclick={() => {
191              draft.roots.splice(i, 1);
192              if (draft.defaultRoot === i) draft.defaultRoot = 0;
193              else if (draft.defaultRoot > i) draft.defaultRoot--;
194              draw();
195            }}
196          >
197            ×
198          </button>
199        </div>
200      );
201    }));
202    updateRemoval();
203  };
204  const close = () => {
205    if (pending) return;
206    dialog.close();
207    fields.dispose();
208  };
209  const up = (
210    <button type="button" class="link" aria-label="move up">
211      ↑
212    </button>
213  ) as HTMLButtonElement;
214  const down = (
215    <button type="button" class="link" aria-label="move down">
216      ↓
217    </button>
218  ) as HTMLButtonElement;
219  // reordering saves at once, so it sits apart from the fields that wait for save
220  const ordering = <div class="project-order">{up}{down}</div> as HTMLElement;
221  const drawOrder = () => {
222    const ids = sidebarOrder(projects.get(), order.get());
223    ordering.hidden = id === undefined;
224    up.disabled = ids[0] === id;
225    down.disabled = ids.at(-1) === id;
226  };
227  for (const [button, delta] of [[up, -1], [down, 1]] as const) {
228    button.addEventListener("click", async () => {
229      if (pending || id === undefined) return;
230      pending = true;
231      status.textContent = "";
232      try {
233        const at = sidebarOrder(projects.get(), order.get()).indexOf(id);
234        await moveGroup(projects.get(), order, id, at + delta);
235      } catch (error) {
236        status.textContent = (error as Error).message;
237      } finally {
238        pending = false;
239        drawOrder();
240      }
241    });
242  }
243  const details = (
244    <div class="project-details">
245      <label class="field">
246        <span>name</span>
247        {name}
248      </label>
249      <div class="field">
250        <span>workspaces</span>
251        {roots}
252        <button
253          type="button"
254          class="link"
255          _onclick={() => {
256            draft.roots.push({ environment: "local", cwd: "" });
257            draw();
258            roots.querySelector<HTMLInputElement>(".project-root:last-child [role=combobox]")
259              ?.focus();
260          }}
261        >
262          add workspace
263        </button>
264      </div>
265      {moveField}
266    </div>
267  ) as HTMLElement;
268  const dismiss = <button type="button" _onclick={close}>cancel</button> as HTMLButtonElement;
269  const save = <button type="submit" class="primary">save</button> as HTMLButtonElement;
270  const controls = (
271    <fieldset class="project-controls">
272      {details}
273      {status}
274      <div class="dialog-actions">
275        {remove}
276        {dismiss}
277        {save}
278      </div>
279    </fieldset>
280  ) as HTMLFieldSetElement;
281  const dialog = (
282    <dialog class="dialog project-dialog" aria-labelledby="project-editor-title">
283      <form
284        _onsubmit={async (event) => {
285          event.preventDefault();
286          if (pending) return;
287          pending = true;
288          controls.disabled = true;
289          status.textContent = "";
290          try {
291            await api(id ? "PUT" : "POST", id ? `/api/projects/${id}` : "/api/projects", {
292              ...draft,
293              name: name.value,
294              ...(!moveField.hidden && move.checked ? { moveConversations: true } : {}),
295            });
296            pending = false;
297            close();
298          } catch (error) {
299            status.textContent = (error as Error).message;
300          } finally {
301            pending = false;
302            controls.disabled = false;
303          }
304        }}
305      >
306        <header class="project-dialog-header">
307          {title}
308          {ordering}
309        </header>
310        {controls}
311      </form>
312    </dialog>
313  ) as HTMLDialogElement;
314  dialog.addEventListener("cancel", (event) => {
315    if (pending) event.preventDefault();
316  });
317  dialog.addEventListener("close", () => fields.dispose());
318  remove.addEventListener("click", async () => {
319    if (
320      !id || pending || !confirm("delete this project? conversations will move to 'no project'")
321    ) {
322      return;
323    }
324    pending = true;
325    controls.disabled = true;
326    try {
327      await api("DELETE", `/api/projects/${id}`);
328      pending = false;
329      close();
330    } catch (error) {
331      status.textContent = (error as Error).message;
332    } finally {
333      pending = false;
334      controls.disabled = false;
335    }
336  });
337  scope.add(() => {
338    fields.dispose();
339    dialog.remove();
340  });
341  return {
342    element: dialog,
343    open(projectId?: string) {
344      id = projectId;
345      const project: Project | undefined = id ? projects.get()[id] : undefined;
346      original = project;
347      move.checked = false;
348      title.textContent = id === "" ? "no project" : project ? "edit project" : "new project";
349      details.hidden = save.hidden = id === "";
350      dismiss.textContent = id === "" ? "done" : "cancel";
351      name.value = project?.name ?? "";
352      draft = {
353        name: name.value,
354        roots: project?.roots.map(({ environment, cwd }) => ({ environment, cwd })) ??
355          [{ environment: "local", cwd: "" }],
356        defaultRoot: project?.defaultRoot ?? 0,
357      };
358      status.textContent = "";
359      remove.hidden = !project;
360      draw();
361      drawOrder();
362      dialog.showModal();
363      if (id !== "") name.focus();
364    },
365  };
366}