@timqi/pier 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/dist/agent/credentials.js +5 -2
  2. package/dist/agent/pi.js +1 -1
  3. package/dist/boards/pier.css +58 -39
  4. package/dist/channels/handoff.js +1 -1
  5. package/dist/cli.js +8 -0
  6. package/dist/db.js +13 -0
  7. package/dist/main.js +7 -2
  8. package/dist/secrets.js +15 -1
  9. package/dist/settings.js +28 -0
  10. package/dist/socket.js +13 -1
  11. package/dist/web/auth.js +152 -29
  12. package/dist/web/instance.js +19 -4
  13. package/dist/web/passkeys.js +438 -0
  14. package/dist/web/public/assets/activity-CImcJ4xF.js +5 -0
  15. package/dist/web/public/assets/activity-CImcJ4xF.js.br +0 -0
  16. package/dist/web/public/assets/activity-CImcJ4xF.js.gz +0 -0
  17. package/dist/web/public/assets/boards-43luxbPd.js +1 -0
  18. package/dist/web/public/assets/boards-43luxbPd.js.br +0 -0
  19. package/dist/web/public/assets/boards-43luxbPd.js.gz +0 -0
  20. package/dist/web/public/assets/explorer-uSHcxQMy.js +4 -0
  21. package/dist/web/public/assets/explorer-uSHcxQMy.js.br +0 -0
  22. package/dist/web/public/assets/explorer-uSHcxQMy.js.gz +0 -0
  23. package/dist/web/public/assets/{index-T-vLXPch.js → index-Dje7tHE4.js} +15 -15
  24. package/dist/web/public/assets/index-Dje7tHE4.js.br +0 -0
  25. package/dist/web/public/assets/index-Dje7tHE4.js.gz +0 -0
  26. package/dist/web/public/assets/index-sQWFDUPu.css +2 -0
  27. package/dist/web/public/assets/index-sQWFDUPu.css.br +0 -0
  28. package/dist/web/public/assets/index-sQWFDUPu.css.gz +0 -0
  29. package/dist/web/public/assets/runs-rggAaD0k.js +1 -0
  30. package/dist/web/public/assets/runs-rggAaD0k.js.br +0 -0
  31. package/dist/web/public/assets/runs-rggAaD0k.js.gz +0 -0
  32. package/dist/web/public/assets/settings-DN20Mj9n.js +5 -0
  33. package/dist/web/public/assets/settings-DN20Mj9n.js.br +0 -0
  34. package/dist/web/public/assets/settings-DN20Mj9n.js.gz +0 -0
  35. package/dist/web/public/assets/task-runs-BF79aZgL.js +3 -0
  36. package/dist/web/public/assets/task-runs-BF79aZgL.js.br +0 -0
  37. package/dist/web/public/assets/task-runs-BF79aZgL.js.gz +0 -0
  38. package/dist/web/public/assets/tasks-MCHgTBcD.js +4 -0
  39. package/dist/web/public/assets/tasks-MCHgTBcD.js.br +0 -0
  40. package/dist/web/public/assets/tasks-MCHgTBcD.js.gz +0 -0
  41. package/dist/web/public/icon-32-amber.png +0 -0
  42. package/dist/web/public/icon-32-emerald.png +0 -0
  43. package/dist/web/public/icon-32-rose.png +0 -0
  44. package/dist/web/public/icon-32-teal.png +0 -0
  45. package/dist/web/public/icon-32-violet.png +0 -0
  46. package/dist/web/public/icon-32.png +0 -0
  47. package/dist/web/public/icon-maskable-512.png +0 -0
  48. package/dist/web/public/icon-touch-192-amber.png +0 -0
  49. package/dist/web/public/icon-touch-192-emerald.png +0 -0
  50. package/dist/web/public/icon-touch-192-rose.png +0 -0
  51. package/dist/web/public/icon-touch-192-teal.png +0 -0
  52. package/dist/web/public/icon-touch-192-violet.png +0 -0
  53. package/dist/web/public/icon-touch-192.png +0 -0
  54. package/dist/web/public/icon.svg +3 -1
  55. package/dist/web/public/index.html +14 -10
  56. package/dist/web/public/index.html.br +0 -0
  57. package/dist/web/public/index.html.gz +0 -0
  58. package/dist/web/public/manifest.webmanifest +10 -10
  59. package/dist/web/public/sw.js +5 -5
  60. package/dist/web/public/sw.js.br +0 -0
  61. package/dist/web/public/sw.js.gz +0 -0
  62. package/dist/web/push.js +15 -3
  63. package/dist/web/server.js +70 -11
  64. package/docs/deploy.md +19 -2
  65. package/package.json +1 -1
  66. package/dist/web/public/assets/activity-BWpiRicQ.js +0 -5
  67. package/dist/web/public/assets/activity-BWpiRicQ.js.br +0 -0
  68. package/dist/web/public/assets/activity-BWpiRicQ.js.gz +0 -0
  69. package/dist/web/public/assets/boards-BnRMjBln.js +0 -1
  70. package/dist/web/public/assets/boards-BnRMjBln.js.br +0 -0
  71. package/dist/web/public/assets/boards-BnRMjBln.js.gz +0 -0
  72. package/dist/web/public/assets/explorer-DLZ0ccgE.js +0 -4
  73. package/dist/web/public/assets/explorer-DLZ0ccgE.js.br +0 -0
  74. package/dist/web/public/assets/explorer-DLZ0ccgE.js.gz +0 -0
  75. package/dist/web/public/assets/index-DMq4awU8.css +0 -2
  76. package/dist/web/public/assets/index-DMq4awU8.css.br +0 -0
  77. package/dist/web/public/assets/index-DMq4awU8.css.gz +0 -0
  78. package/dist/web/public/assets/index-T-vLXPch.js.br +0 -0
  79. package/dist/web/public/assets/index-T-vLXPch.js.gz +0 -0
  80. package/dist/web/public/assets/runs-oFQ162um.js +0 -1
  81. package/dist/web/public/assets/runs-oFQ162um.js.br +0 -0
  82. package/dist/web/public/assets/runs-oFQ162um.js.gz +0 -0
  83. package/dist/web/public/assets/settings-_t0Js-DJ.js +0 -5
  84. package/dist/web/public/assets/settings-_t0Js-DJ.js.br +0 -0
  85. package/dist/web/public/assets/settings-_t0Js-DJ.js.gz +0 -0
  86. package/dist/web/public/assets/task-runs-m3ufvOQI.js +0 -3
  87. package/dist/web/public/assets/task-runs-m3ufvOQI.js.br +0 -0
  88. package/dist/web/public/assets/task-runs-m3ufvOQI.js.gz +0 -0
  89. package/dist/web/public/assets/tasks-C1ATvPUc.js +0 -4
  90. package/dist/web/public/assets/tasks-C1ATvPUc.js.br +0 -0
  91. package/dist/web/public/assets/tasks-C1ATvPUc.js.gz +0 -0
  92. package/dist/web/public/icon.svg.br +0 -0
  93. package/dist/web/public/icon.svg.gz +0 -0
  94. package/dist/web/public/manifest.webmanifest.br +0 -0
  95. package/dist/web/public/manifest.webmanifest.gz +0 -0
@@ -23,22 +23,23 @@
23
23
  </script>
24
24
  <!-- Installed as a home-screen app: standalone window, own icon. The
25
25
  manifest shares the workbench's cookie boundary. -->
26
- <link rel="manifest" href="/manifest.webmanifest" crossorigin="use-credentials" />
26
+ <link rel="manifest" href="/app/manifest.webmanifest" crossorigin="use-credentials" />
27
27
  <!-- SVG first (scales to any tab/bookmark size), 32px PNG for the engines
28
28
  that still ignore it. iOS wants an opaque square and rounds it itself. -->
29
- <link rel="icon" type="image/svg+xml" href="/icon.svg" />
30
- <link rel="icon" type="image/png" sizes="32x32" href="/icon-32.png" />
31
- <link rel="apple-touch-icon" href="/icon-touch-192.png" />
29
+ <link rel="icon" type="image/svg+xml" href="/app/icon.svg" />
30
+ <link rel="icon" type="image/png" sizes="32x32" href="/app/icon-32.png" />
31
+ <link rel="apple-touch-icon" href="/app/icon-touch-192.png" />
32
32
  <meta name="mobile-web-app-capable" content="yes" />
33
33
  <meta name="apple-mobile-web-app-capable" content="yes" />
34
- <meta name="apple-mobile-web-app-title" content="Pier" />
34
+ <!-- No apple-mobile-web-app-title: it would override the manifest's
35
+ short_name, which is where the instance's name is (web/server.ts). -->
35
36
  <!-- Without this iOS paints the status-bar strip in a system grey that
36
37
  belongs to no part of the app. Translucent hands the strip to the page,
37
38
  which style.css pays for with the safe-area inset. -->
38
39
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
39
40
  <title>Pier</title>
40
- <script type="module" crossorigin src="/assets/index-T-vLXPch.js"></script>
41
- <link rel="stylesheet" crossorigin href="/assets/index-DMq4awU8.css">
41
+ <script type="module" crossorigin src="/app/assets/index-Dje7tHE4.js"></script>
42
+ <link rel="stylesheet" crossorigin href="/app/assets/index-sQWFDUPu.css">
42
43
  </head>
43
44
  <!-- The document never scrolls: this is a fixed-viewport workbench, and every
44
45
  scrollable region is an inner pane. h-dvh, not h-screen, because 100vh can
@@ -60,7 +61,7 @@
60
61
  <header class="flex h-12 flex-none items-center gap-2 px-3">
61
62
  <!-- The same mark as the tab and the home-screen icon, so an installed
62
63
  Pier looks like the thing you installed. -->
63
- <img src="/icon.svg" alt="" width="20" height="20" class="h-7 w-7 flex-none rounded-lg border border-neutral-200/70 bg-white p-1 shadow-xs" />
64
+ <img src="/app/icon.svg" alt="" width="20" height="20" class="h-7 w-7 flex-none rounded-lg border border-neutral-200/70 bg-white p-1 shadow-xs" />
64
65
  <h1 class="text-[17px] font-semibold tracking-tight">Pier</h1>
65
66
  <!-- A link, not a label: AGPL-3.0 section 13 asks a network service to
66
67
  point its users at the source it is running. Beside the title
@@ -253,8 +254,11 @@
253
254
  <button type="button" id="image-prev" aria-label="Previous image" class="lightbox-btn left-2 top-1/2 -translate-y-1/2"><span data-icon="ChevronLeft" class="h-6 w-6"></span></button>
254
255
  <button type="button" id="image-next" aria-label="Next image" class="lightbox-btn right-2 top-1/2 -translate-y-1/2"><span data-icon="ChevronRight" class="h-6 w-6"></span></button>
255
256
  <!-- The way out on a touch screen: tapping the image now zooms it, and the
256
- scrim beside a fitted shot is a 4vw strip — not a thumb's target. -->
257
- <button type="button" id="image-close" aria-label="Close" class="lightbox-btn right-2 top-2"><span data-icon="X" class="h-6 w-6"></span></button>
257
+ scrim beside a fitted shot is a 4vw strip — not a thumb's target. The
258
+ inset is its own: the dialog sits outside `main`, so style.css's rule
259
+ never reaches it and `top-2` alone is under iOS's status bar, where
260
+ the tap belongs to the system. -->
261
+ <button type="button" id="image-close" aria-label="Close" class="lightbox-btn right-2 top-[calc(0.5rem+env(safe-area-inset-top))]"><span data-icon="X" class="h-6 w-6"></span></button>
258
262
  </dialog>
259
263
 
260
264
  <!-- Text preview of an agent attachment — the Files view's code pane fills
Binary file
Binary file
@@ -1,26 +1,26 @@
1
1
  {
2
- "id": "/",
2
+ "id": "/app/",
3
3
  "name": "Pier",
4
4
  "short_name": "Pier",
5
5
  "description": "A self-hosted workspace for coding agents: sessions, tasks and files, in one workbench.",
6
6
  "lang": "en",
7
7
  "dir": "ltr",
8
- "start_url": "/",
9
- "scope": "/",
8
+ "start_url": "/app/",
9
+ "scope": "/app/",
10
10
  "display": "standalone",
11
11
  "background_color": "#f1f4f3",
12
12
  "theme_color": "#f1f4f3",
13
13
  "categories": ["developer", "productivity", "utilities"],
14
14
  "launch_handler": { "client_mode": "focus-existing" },
15
15
  "icons": [
16
- { "src": "/icon.svg", "sizes": "any", "type": "image/svg+xml" },
17
- { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
18
- { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" },
19
- { "src": "/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
16
+ { "src": "/app/icon.svg", "sizes": "any", "type": "image/svg+xml" },
17
+ { "src": "/app/icon-192.png", "sizes": "192x192", "type": "image/png" },
18
+ { "src": "/app/icon-512.png", "sizes": "512x512", "type": "image/png" },
19
+ { "src": "/app/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
20
20
  ],
21
21
  "shortcuts": [
22
- { "name": "Tasks", "url": "/#/tasks", "icons": [{ "src": "/icon-192.png", "sizes": "192x192" }] },
23
- { "name": "Activity", "url": "/#/activity", "icons": [{ "src": "/icon-192.png", "sizes": "192x192" }] },
24
- { "name": "Settings", "url": "/#/settings", "icons": [{ "src": "/icon-192.png", "sizes": "192x192" }] }
22
+ { "name": "Tasks", "url": "/app/#/tasks", "icons": [{ "src": "/app/icon-192.png", "sizes": "192x192" }] },
23
+ { "name": "Activity", "url": "/app/#/activity", "icons": [{ "src": "/app/icon-192.png", "sizes": "192x192" }] },
24
+ { "name": "Settings", "url": "/app/#/settings", "icons": [{ "src": "/app/icon-192.png", "sizes": "192x192" }] }
25
25
  ]
26
26
  }
@@ -19,11 +19,11 @@ button{margin-top:1rem;padding:.5rem 1rem;border:1px solid light-dark(#d4d4d4,#3
19
19
  <button onclick="location.reload()">Try again</button></main></body></html>`;
20
20
 
21
21
  /** A payload URL is data: resolved against this origin, and replaced by the
22
- * root if it lands anywhere else — a notification may not open a foreign page. */
22
+ * workbench if it lands anywhere else — a notification may not open a foreign page. */
23
23
  const here = (url) => {
24
- const root = new URL("/", self.location.origin);
24
+ const root = new URL("/app/", self.location.origin);
25
25
  try {
26
- const target = new URL(url || "/", self.location.origin);
26
+ const target = new URL(url || "/app/", self.location.origin);
27
27
  return target.origin === root.origin ? target : root;
28
28
  } catch {
29
29
  return root;
@@ -75,8 +75,8 @@ self.addEventListener("push", (event) => {
75
75
  // second turn silently replaces the first — no sound, no re-alert, which
76
76
  // reads as "notifications stopped working". Ignored where unsupported.
77
77
  renotify: true,
78
- icon: "/icon-192.png",
79
- badge: "/icon-32.png",
78
+ icon: "/app/icon-192.png",
79
+ badge: "/app/icon-32.png",
80
80
  timestamp: Date.now(),
81
81
  data: { url: here(data.url).href },
82
82
  });
Binary file
Binary file
package/dist/web/push.js CHANGED
@@ -59,7 +59,7 @@ export class PushStore {
59
59
  }
60
60
  list() {
61
61
  return this.#db
62
- .prepare(`SELECT endpoint, p256dh, auth, label, created_at AS createdAt
62
+ .prepare(`SELECT endpoint, p256dh, auth, label, created_at AS createdAt, session_id AS sessionId
63
63
  FROM push_subscriptions ORDER BY created_at DESC`)
64
64
  .all();
65
65
  }
@@ -190,7 +190,7 @@ export function registerPushRoutes(app, deps) {
190
190
  await deliver({
191
191
  title: sessionLabel(await summary(e.sessionId)),
192
192
  body: preview(text) || "Turn finished.",
193
- url: `/#/session/${encodeURIComponent(e.sessionId)}`,
193
+ url: `/app/#/session/${encodeURIComponent(e.sessionId)}`,
194
194
  tag: e.sessionId,
195
195
  });
196
196
  })().catch((err) => log.error(`delivering a push for ${e.sessionId} failed`, err));
@@ -200,6 +200,18 @@ export function registerPushRoutes(app, deps) {
200
200
  });
201
201
  // --- routes ---------------------------------------------------------------------
202
202
  app.get("/api/push", (c) => c.json({ publicKey: store.identity().publicKey }));
203
+ // Never the keys: a row with them is a sender, not a device.
204
+ app.get("/api/push/subscriptions", (c) => {
205
+ const me = sessionIdOf(c);
206
+ return c.json({
207
+ devices: store.list().map(({ endpoint, label, createdAt, sessionId }) => ({
208
+ endpoint,
209
+ label,
210
+ createdAt,
211
+ current: sessionId === me,
212
+ })),
213
+ });
214
+ });
203
215
  app.post("/api/push/subscribe", async (c) => {
204
216
  const body = await c.req.json().catch(() => null);
205
217
  const target = parseTarget(body);
@@ -233,7 +245,7 @@ export function registerPushRoutes(app, deps) {
233
245
  const { sent, failed } = await deliver({
234
246
  title: "Pier",
235
247
  body: "Notifications are working.",
236
- url: "/",
248
+ url: "/app/",
237
249
  tag: "pier-test",
238
250
  });
239
251
  if (!sent && !failed)
@@ -21,6 +21,7 @@ import { isThinkingLevel, SESSION_TITLE_MAX } from "../core/types.js";
21
21
  import { saveInbound } from "../core/inbox.js";
22
22
  import { MAX_INBOUND_BYTES } from "../core/inbound-file.js";
23
23
  import {} from "./session-state.js";
24
+ import { ACCENTS, DEFAULT_ACCENT } from "../settings.js";
24
25
  import { registerInstanceRoutes } from "./instance.js";
25
26
  import { registerProviderRoutes } from "./providers.js";
26
27
  const log = logger("web");
@@ -46,6 +47,34 @@ export const tabPrefix = (title, host) => [title?.trim(), host.trim()].filter(Bo
46
47
  export const withTabPrefix = (html, prefix) => prefix
47
48
  ? html.replace("<title>Pier</title>", `<title>${prefix.replace(/&/g, "&amp;").replace(/</g, "&lt;")} - Pier</title>`)
48
49
  : html;
50
+ /** The accent rides on `<html>` so the first paint is already in it; the
51
+ * stylesheet's `[data-accent]` ramps do the rest (style.css). Safari and the
52
+ * iOS Home Screen take the PNG links, never the SVG or the manifest, so those
53
+ * point at the preset's pre-rendered copy (`icon-32-<accent>.png`,
54
+ * `icon-touch-192-<accent>.png`, beside the defaults in public/). A preset
55
+ * name, already validated — never a colour. */
56
+ export const withAccent = (html, accent) => accent
57
+ ? html
58
+ .replace('<html lang="en">', `<html lang="en" data-accent="${accent}">`)
59
+ .replace('/app/icon-32.png', `/app/icon-32-${accent}.png`)
60
+ .replace('/app/icon-touch-192.png', `/app/icon-touch-192-${accent}.png`)
61
+ : html;
62
+ /** Two Piers on one phone need two names and two colours: the manifest names
63
+ * the instance (`$PIER_TITLE`) and paints its chrome with the accent's 600
64
+ * step, the same one the icon's plate takes. */
65
+ export const instanceManifest = (template, title, accent) => {
66
+ const name = title?.trim() || "Pier";
67
+ return {
68
+ ...template,
69
+ name,
70
+ short_name: name.slice(0, 12),
71
+ theme_color: ACCENTS[accent || DEFAULT_ACCENT],
72
+ };
73
+ };
74
+ /** The plate colour is the one thing the served SVG changes; the mark stays
75
+ * white. The PNG fallbacks beside it are static and keep the shipped blue. */
76
+ export const ICON_PLATE = "#4f46e5";
77
+ export const withAccentIcon = (svg, accent) => svg.replace(`fill="${ICON_PLATE}"`, `fill="${ACCENTS[accent || DEFAULT_ACCENT]}"`);
49
78
  const HEARTBEAT_MS = 15_000;
50
79
  /** Past this much frame text in flight a stream is dropped. */
51
80
  const SSE_HIGH_WATER = 4 * 1024 * 1024;
@@ -72,7 +101,7 @@ function boundedWriter(stream, who) {
72
101
  }
73
102
  // Canonical base64 only: Buffer.from(.., "base64") happily "decodes" garbage.
74
103
  const BASE64_RE = /^[A-Za-z0-9+/]+={0,2}$/;
75
- export function createServer({ factory, router, hub, sessions: state, config, packages, providers, settings, catalog, names, onToolsChanged, validateCustomTools, secrets, onUnlocked, reload, updates, updater, backgroundRuns, activeBackgroundRunCounts, taskSessions, channelOf, }) {
104
+ export function createServer({ factory, router, hub, sessions: state, config, packages, providers, settings, catalog, names, onToolsChanged, validateCustomTools, secrets, passkeys, onUnlocked, reload, updates, updater, backgroundRuns, activeBackgroundRunCounts, taskSessions, channelOf, }) {
76
105
  const app = new Hono();
77
106
  const epoch = randomUUID();
78
107
  // One frame shared by synchronous fan-out to every watching tab.
@@ -506,6 +535,7 @@ export function createServer({ factory, router, hub, sessions: state, config, pa
506
535
  validateCustomTools,
507
536
  onUnlocked,
508
537
  onSettingsChanged: () => recycle("instance settings"),
538
+ passkeys,
509
539
  });
510
540
  registerProviderRoutes(app, providers, () => recycle("provider configuration"));
511
541
  registerConfigRoutes(app, { factory, config, onConfigWritten: () => recycle("an agent file") });
@@ -515,15 +545,16 @@ export function createServer({ factory, router, hub, sessions: state, config, pa
515
545
  // serveStatic resolves `root` against the working directory, and an installed
516
546
  // Pier is started from wherever the operator happens to be.
517
547
  const bundle = fileURLToPath(new URL("./public", import.meta.url));
548
+ // The workbench lives under /app/ (vite.config.ts `base`): a manifest scope
549
+ // is a path prefix with no exclusions, so at `/` an installed Pier would
550
+ // capture the Show pages at /boards/* and /p/*.
551
+ app.get("/", (c) => c.redirect("/app/"));
518
552
  // The tab says which instance this is: mistaking staging for production is
519
553
  // the mistake worth a few lines. Behind the auth guard, so a stranger at
520
554
  // /login learns neither fact.
521
555
  const prefix = tabPrefix(process.env.PIER_TITLE, hostname().split(".")[0] ?? "");
522
556
  let shell = null;
523
- // Answers from the patched string, not from disk, so the precompressed
524
- // siblings below cannot cover it. Exact path: never the SSE streams.
525
- app.use("/", compress());
526
- app.get("/", async (c, next) => {
557
+ const serveShell = async (c, next) => {
527
558
  // A cached index must not name bundles a release has replaced.
528
559
  c.header("cache-control", "private, no-cache");
529
560
  if (shell === null) {
@@ -536,27 +567,55 @@ export function createServer({ factory, router, hub, sessions: state, config, pa
536
567
  return next();
537
568
  }
538
569
  }
539
- return c.html(shell);
540
- });
570
+ // Per request, not cached: the accent is a setting.
571
+ return c.html(withAccent(shell, settings.get().accent));
572
+ };
573
+ // Answers from the patched string, not from disk, so the precompressed
574
+ // siblings below cannot cover it. Exact paths: never the SSE streams.
575
+ for (const path of ["/app", "/app/"]) {
576
+ app.use(path, compress());
577
+ app.get(path, serveShell);
578
+ }
579
+ // The two install assets that carry the instance's identity, rendered from
580
+ // the shipped files; `no-cache` so a changed accent shows on the next load.
581
+ // Unreadable → the static copy below, and the log says so.
582
+ const identity = (file, render, type) => async (c, next) => {
583
+ let text;
584
+ try {
585
+ text = await readFile(join(bundle, file), "utf8");
586
+ }
587
+ catch (err) {
588
+ log.warn(`${file} unreadable, serving it unpatched: ${String(err)}`);
589
+ return next();
590
+ }
591
+ c.header("cache-control", "private, no-cache");
592
+ return c.body(render(text, settings.get().accent), 200, { "content-type": type });
593
+ };
594
+ app.get("/app/manifest.webmanifest", identity("manifest.webmanifest", (text, accent) => JSON.stringify(instanceManifest(JSON.parse(text), process.env.PIER_TITLE, accent)), "application/manifest+json"));
595
+ app.get("/app/icon.svg", identity("icon.svg", withAccentIcon, "image/svg+xml"));
541
596
  // The one unhashed asset: an installed app keeps its worker until the
542
597
  // re-fetched script differs, so a cached copy is a fix that never ships.
543
- app.get("/sw.js", async (c, next) => {
598
+ app.get("/app/sw.js", async (c, next) => {
544
599
  c.header("cache-control", "private, no-cache");
545
600
  await next();
546
601
  });
547
602
  // Hashed bundles never change under their name; without this the auth
548
603
  // layer's bare `private` costs a revalidation round trip per bundle per open.
549
- app.get("/assets/*", async (c, next) => {
604
+ app.get("/app/assets/*", async (c, next) => {
550
605
  c.header("cache-control", "private, max-age=31536000, immutable");
551
606
  await next();
552
607
  });
553
608
  // The build writes `.br`/`.gz` siblings (vite.config.ts). serveStatic sets
554
609
  // Vary only when it selects one; identity must carry it too, or a cache can
555
610
  // reuse that response for a later Brotli request.
556
- app.use("/*", async (c, next) => {
611
+ app.use("/app/*", async (c, next) => {
557
612
  await next();
558
613
  c.header("Vary", "Accept-Encoding");
559
614
  });
560
- app.use("/*", serveStatic({ root: relative(process.cwd(), bundle) || ".", precompressed: true }));
615
+ app.use("/app/*", serveStatic({
616
+ root: relative(process.cwd(), bundle) || ".",
617
+ rewriteRequestPath: (path) => path.replace(/^\/app/, ""),
618
+ precompressed: true,
619
+ }));
561
620
  return app;
562
621
  }
package/docs/deploy.md CHANGED
@@ -96,7 +96,21 @@ pier restart
96
96
  ```
97
97
 
98
98
  - Changing or recovering the password signs out every browser.
99
- - One browser: Settings → Instance → Signed-in devices.
99
+ - One browser: Settings → Security → Signed-in devices.
100
+ - Passkeys (Settings → Security, needs an https public URL): while one is
101
+ registered the password is refused. A browser without your passkey signs in
102
+ through a one-time link (two minutes, single use) from a shell on the host:
103
+
104
+ ```sh
105
+ pier login
106
+ ```
107
+
108
+ - Locked out of every passkey? Drop the rows; the table is read live, so no
109
+ restart:
110
+
111
+ ```sh
112
+ sqlite3 ~/.pier/db/pier.db 'DELETE FROM passkeys'
113
+ ```
100
114
  - A browser session expires 7 days after its last request, and 90 days after
101
115
  it signed in however often it is used.
102
116
  - However a session ends, its push subscription goes with it.
@@ -231,7 +245,10 @@ Loopback bind; reach it over a tunnel, not a wider bind:
231
245
 
232
246
  - `ssh -L 3141:localhost:3141 server`
233
247
  - Tailscale, or Cloudflare Tunnel — no open port, TLS terminates outside.
234
- - A reverse proxy (Caddy, nginx): terminate TLS there, preserve the external
248
+ - A reverse proxy (Caddy, nginx) **on the same host**, connecting over
249
+ loopback: forwarded headers are trusted from a loopback peer only, so a proxy
250
+ on another machine gets one throttle bucket for all its clients and a cookie
251
+ without `Secure`. Terminate TLS there, preserve the external
235
252
  `Host` (or pass `X-Forwarded-Host`), and **set** `X-Forwarded-For` to the
236
253
  client — nginx `proxy_set_header X-Forwarded-For $remote_addr;`, Caddy by
237
254
  default. Pier reads the rightmost hop, so appending the peer is safe too
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timqi/pier",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "A self-hosted workspace for coding agents: web workbench and IM channels in front of Pi sessions",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": "github:timqi/pier",
@@ -1,5 +0,0 @@
1
- import{C as e,D as t,K as n,N as r,Q as i,U as a,X as o,Y as s,q as c,rt as l,t as u,u as d,z as f}from"./index-T-vLXPch.js";var p=[[`path`,{d:`M3 5h.01`}],[`path`,{d:`M3 12h.01`}],[`path`,{d:`M3 19h.01`}],[`path`,{d:`M8 5h13`}],[`path`,{d:`M8 12h13`}],[`path`,{d:`M8 19h13`}]],m=[[`path`,{d:`m10.586 5.414-5.172 5.172`}],[`path`,{d:`m18.586 13.414-5.172 5.172`}],[`path`,{d:`M6 12h12`}],[`circle`,{cx:`12`,cy:`20`,r:`2`}],[`circle`,{cx:`12`,cy:`4`,r:`2`}],[`circle`,{cx:`20`,cy:`12`,r:`2`}],[`circle`,{cx:`4`,cy:`12`,r:`2`}]],h=(e,t={})=>{let n=document.createElementNS(`http://www.w3.org/2000/svg`,e);for(let[e,r]of Object.entries(t))n.setAttribute(e,r);return n},g=e=>e===null?`-`:n(Date.now()-e),_={invocation:{stroke:`stroke-neutral-400`,fill:`fill-neutral-400`},callback:{stroke:`stroke-cyan-500`,fill:`fill-cyan-500`,dash:`6 5`},message:{stroke:`stroke-amber-500`,fill:`fill-amber-500`,dash:`2 5`}},v={scheduler:`border-violet-200 bg-violet-50 text-violet-700`,console:`border-indigo-200 bg-indigo-50 text-indigo-700`,task:`border-amber-200 bg-amber-50 text-amber-700`,process:`border-dashed border-neutral-300 bg-neutral-50 text-neutral-500`,streaming:`border-emerald-200 bg-white font-medium text-neutral-800`,idle:`border-neutral-200 bg-white font-medium text-neutral-600`},y=300,b=4;function x(e,t){let n,r=null,i=!1,a=!1,o=()=>{clearTimeout(n),n=setTimeout(()=>{i||a||!r||r.dataset.closing!==void 0||(d(),r=null)},120)};e.onpointerenter=s=>{s.pointerType!==`touch`&&(i=!0,clearTimeout(n),!(r?.isConnected&&r.dataset.closing===void 0)&&(n=setTimeout(()=>{!e.isConnected||!i||(r=u(e,t),r.onpointerenter=()=>{a=!0,clearTimeout(n)},r.onpointerleave=()=>{a=!1,o()})},y)))},e.onpointerleave=()=>{i=!1,o()},e.onpointerdown=()=>clearTimeout(n)}function S(e){let t,n=!1;e.classList.add(`cursor-grab`),e.onpointerdown=r=>{n=!1,!(r.button!==0||r.pointerType===`touch`||r.target.closest(`[data-node], path`))&&(t={x:r.clientX,y:r.clientY,left:e.scrollLeft,top:e.scrollTop},e.setPointerCapture(r.pointerId),e.classList.add(`cursor-grabbing`,`select-none`))},e.onpointermove=r=>{if(!t)return;let i=r.clientX-t.x,a=r.clientY-t.y;(Math.abs(i)>b||Math.abs(a)>b)&&(n=!0),e.scrollLeft=t.left-i,e.scrollTop=t.top-a};let r=()=>{t=void 0,e.classList.remove(`cursor-grabbing`,`select-none`)};e.onpointerup=r,e.onpointercancel=r,e.addEventListener(`click`,e=>{n&&(e.stopPropagation(),e.preventDefault())},!0)}function C(n,u,d){let y=`sessions`,b=`active`,C={sessions:[],runs:[],messages:[]},w=``,T=o(async()=>{let e=b,t=await i(`/api/activity?scope=${e}`,`Failed to load activity`);if(!t.ok){w=``,n.replaceChildren(c(`p`,`p-4 text-[13px] text-red-600`,t.error));return}if(e!==b)return;let r=JSON.stringify(t.value);if(`${e}:${r}`===w)return;w=`${e}:${r}`;let a=t.value;C={...a,sessions:a.sessions.map(e=>({...e,title:l(e.title)}))},E()});function E(){let e=f(r([[`Sessions`,`sessions`,p],[`Relationships`,`dependencies`,m]],y,e=>{y=e,E()}),c(`span`,`ml-auto text-[11.5px] text-neutral-400`,`${C.sessions.length} sessions · ${C.runs.length} runs`),r([[`Current`,`active`],[`Last 24h`,`recent`]],b,e=>{b=e,E(),T()})),t=c(`div`,`automation-list min-h-0 flex-1 overflow-auto`);y===`sessions`?D(t):k(t),n.replaceChildren(e,t)}function D(e){let n=document.createElement(`table`);n.className=`w-full table-fixed text-left text-[12.5px]`,n.innerHTML=`<thead class="sticky top-0 bg-neutral-50 text-[10.5px] uppercase tracking-wide text-neutral-400 shadow-[inset_0_-1px_0_var(--color-neutral-200)]"><tr>
2
- <th class="w-[50%] px-4 py-2 font-semibold md:w-[34%]">Session</th>
3
- <th class="hidden w-[38%] px-2 py-2 font-semibold md:table-cell">Project</th>
4
- <th class="px-2 py-2 font-semibold md:w-[14%]">State</th>
5
- <th class="px-2 py-2 font-semibold">In state</th></tr></thead>`;let r=document.createElement(`tbody`);for(let e of C.sessions){let t=document.createElement(`tr`);t.className=`cursor-pointer border-b border-neutral-100 transition-colors hover:bg-neutral-50`,t.onclick=()=>u(e.id);let n=c(`button`,`block w-full cursor-pointer truncate text-left font-medium text-neutral-800`,e.title??s(e.cwd));n.setAttribute(`type`,`button`),n.title=`${e.id}\n${e.cwd}`,n.onclick=t=>{t.stopPropagation(),u(e.id)},t.append(c(`td`,`px-4 py-2.5`,n,c(`div`,`truncate font-mono text-[11px] text-neutral-400 md:hidden`,e.cwd||`-`)),c(`td`,`hidden truncate px-2 py-2.5 font-mono text-[11.5px] text-neutral-500 md:table-cell`,e.cwd||`-`),c(`td`,`px-2 py-2.5`,O(e.state)),c(`td`,`px-2 py-2.5 font-mono text-[11.5px] text-neutral-400`,g(e.stateSince))),r.append(t)}n.append(r),e.append(n),C.sessions.length||e.append(c(`div`,`p-4`,t(b===`active`?`No active sessions.`:`No sessions in the last 24 hours.`)))}function O(t){return t===`streaming`?e(t,`bg-emerald-50 text-emerald-700 ring-emerald-200`,`animate-pulse bg-emerald-500`):e(t,`bg-neutral-100 text-neutral-600 ring-neutral-200`,`bg-neutral-300`)}function k(e){let n=new Map,r=[],i=(e,t,r,i)=>{n.has(e)||n.set(e,{id:e,label:t,kind:r,state:i})};for(let e of C.sessions)i(e.id,e.title??e.id.slice(0,10),`session`,e.state);for(let e of C.runs){let t=e.invokedBySessionId??`scheduler`,n=e.targetSessionId??(e.context.definition.action.type===`bash`?`process:${e.id}`:`task:${e.id}`);i(t,t===`scheduler`?`Scheduler`:t.slice(0,10),t===`scheduler`?`scheduler`:`session`),i(n,e.targetSessionId?e.targetSessionId.slice(0,10):e.context.definition.action.type===`bash`?`Bash process`:`Task`,e.targetSessionId?`session`:e.context.definition.action.type===`bash`?`process`:`task`),r.push({from:t,to:n,run:e,kind:`invocation`}),e.callbackSessionId&&(i(e.callbackSessionId,e.callbackSessionId.slice(0,10),`session`),r.push({from:n,to:e.callbackSessionId,run:e,kind:`callback`}))}for(let e of C.messages){if(!e.fromSessionId||!e.toSessionId||e.fromSessionId===e.toSessionId)continue;let t=C.runs.find(t=>t.id===e.runId);if(t){for(let t of[e.fromSessionId,e.toSessionId])i(t,t===`console`?`Console`:t.slice(0,10),t===`console`?`console`:`session`);r.push({from:e.fromSessionId,to:e.toSessionId,run:t,kind:`message`})}}if(!n.size){e.append(c(`div`,`p-4`,t(b===`active`?`No active dependencies.`:`No dependencies in the last 24 hours.`)));return}let a=[...n.values()],o=new Set(r.map(e=>e.to)),s=new Map,l=a.filter(e=>!o.has(e.id)).map(e=>e.id);l.length||l.push(a[0].id);for(let e of l)s.set(e,0);for(;l.length;){let e=l.shift();for(let t of r)t.from!==e||s.has(t.to)||(s.set(t.to,s.get(e)+1),l.push(t.to))}for(let e of a)s.has(e.id)||s.set(e.id,0);let f=new Map;for(let e of a){let t=s.get(e.id);f.set(t,[...f.get(t)??[],e])}let p=Math.max(...f.keys())+1,m=Math.max(...[...f.values()].map(e=>e.length)),g=r.some(e=>e.from!==e.to&&s.get(e.from)===s.get(e.to))?40:0,y=56+p*180+(p-1)*110+g,w=56+m*46+(m-1)*30,T=new Map;for(let[e,t]of f)t.forEach((t,n)=>T.set(t.id,{x:28+e*290+90,y:28+n*76+23}));let E=c(`div`,`relative`);E.style.width=`${y}px`,E.style.height=`${w}px`;let D=h(`svg`,{width:String(y),height:String(w),viewBox:`0 0 ${y} ${w}`,class:`absolute inset-0`}),O=h(`defs`);for(let[e,t]of Object.entries(_)){let n=h(`marker`,{id:`activity-arrow-${e}`,viewBox:`0 0 10 10`,refX:`9`,refY:`5`,markerWidth:`6`,markerHeight:`6`,orient:`auto-start-reverse`});n.append(h(`path`,{d:`M 0 0 L 10 5 L 0 10 z`,class:t.fill})),O.append(n)}D.append(O);let k=new Set;for(let e of r){let t=`${e.from}→${e.to}:${e.kind}`;if(k.has(t))continue;k.add(t);let n=T.get(e.from),r=T.get(e.to),i=n.x===r.x?`M ${n.x+90} ${n.y} C ${n.x+90+46} ${n.y}, ${r.x+90+46} ${r.y}, ${r.x+90} ${r.y}`:(()=>{let e=r.x>n.x,t=n.x+(e?90:-90),i=r.x+(e?-90:90),a=Math.max(36,Math.abs(i-t)*.45)*(e?1:-1);return`M ${t} ${n.y} C ${t+a} ${n.y}, ${i-a} ${r.y}, ${i} ${r.y}`})(),a=_[e.kind],o=h(`path`,{d:i,fill:`none`,class:`cursor-pointer ${a.stroke}`,"stroke-width":`1.5`,"marker-end":`url(#activity-arrow-${e.kind})`});a.dash&&o.setAttribute(`stroke-dasharray`,a.dash),o.onclick=()=>d(e.run.id),D.append(o)}E.append(D);for(let e of a){let t=T.get(e.id),n=e.kind===`session`?e.state===`streaming`?v.streaming:v.idle:v[e.kind],r=c(e.kind===`session`?`button`:`div`,`absolute flex items-center gap-2 rounded-xl border px-3 text-left text-[11.5px] shadow-xs ${n} ${e.kind===`session`?`cursor-pointer hover:bg-neutral-50`:`cursor-default`}`,...e.kind===`session`?[c(`span`,`inline-block h-1.5 w-1.5 flex-none rounded-full ${e.state===`streaming`?`animate-pulse bg-emerald-500`:`bg-neutral-300`}`)]:[],c(`span`,`min-w-0 truncate`,e.label));if(r.dataset.node=e.id,r.style.left=`${t.x-90}px`,r.style.top=`${t.y-23}px`,r.style.width=`180px`,r.style.height=`46px`,r.title=e.label===e.id?e.id:`${e.label}\n${e.id}`,e.kind===`session`){r.setAttribute(`type`,`button`),r.onclick=()=>u(e.id);let t=C.sessions.find(t=>t.id===e.id);t&&t.createdAt!==null&&x(r,{...t,createdAt:t.createdAt})}E.append(r)}S(e);let A=e=>c(`span`,`inline-block h-2 w-2 rounded-full ${e}`),j=e=>c(`span`,`inline-block h-0 w-6 border-t-2 ${e}`);e.append(c(`div`,`w-max p-4`,E),c(`div`,`flex flex-wrap gap-x-5 gap-y-1.5 border-t border-neutral-200 bg-neutral-50/60 px-4 py-2 text-[11px] text-neutral-500`,c(`span`,`inline-flex items-center gap-2`,j(`border-solid border-neutral-400`),`task invocation`),c(`span`,`inline-flex items-center gap-2 text-cyan-700`,j(`border-dashed border-cyan-500`),`callback`),c(`span`,`inline-flex items-center gap-2 text-amber-700`,j(`border-dotted border-amber-500`),`control message`),c(`span`,`inline-flex items-center gap-1.5`,A(`bg-emerald-500`),`streaming`),c(`span`,`inline-flex items-center gap-1.5`,A(`bg-neutral-300`),`idle`)))}let A=a(n,e=>{e===`dependencies`?y=`dependencies`:e===`sessions`&&(y=`sessions`),T()},()=>{n.replaceChildren(),C={sessions:[],runs:[],messages:[]},w=``});return Object.assign(A,{refresh(){A.visible&&T()}})}export{C as createActivityView};
@@ -1 +0,0 @@
1
- import{D as e,E as t,J as n,Q as r,R as i,U as a,W as o,Z as s,j as c,nt as l,q as u,tt as d,w as f}from"./index-T-vLXPch.js";var p=e=>e.public&&e.token?`/p/${e.slug}-${e.token}/`:`/boards/${e.slug}/`;function m(m,h){let g=[],_=``,v=u(`header`,`pagehead sticky top-[-8px] z-30 max-md:hidden`,c(`Boards`)),y=u(`div`,`px-4 pb-5 pt-1`);m.append(u(`div`,`min-h-0 flex-1 overflow-y-auto`,v,y));async function b(e,t){let n=await l(`/api/boards/${e.slug}`,{public:t},`PATCH`);if(n.ok)return e.token=(await n.json().catch(()=>({}))).token??``,w();_=await s(n,`Could not change ${e.slug}`),await T()}async function x(e){_=await d(`/api/boards/${e}`,`DELETE`,`Could not delete ${e}`)??``,await T()}function S(e){let t=u(`div`,`group flex flex-col gap-1 py-2.5`),r=document.createElement(`a`);r.className=`min-w-0 flex-1 truncate text-[13px] font-medium text-neutral-800 hover:text-indigo-700 max-md:basis-full`,r.href=p(e),r.target=`_blank`,r.rel=`noreferrer`,r.textContent=e.title;let a=u(`div`,`flex flex-wrap items-center gap-x-2 gap-y-1`,r),s=i(``,``,e.public,t=>{e.public=t,b(e,t)});s.className=`flex flex-none cursor-pointer items-center gap-1.5 text-[11.5px] text-neutral-500`,s.append(u(`span`,``,`Public`));let c=f(`Delete`,`flex-none cursor-pointer text-[11.5px] text-neutral-400 opacity-0 transition-opacity hover:text-red-600 group-hover:opacity-100 pointer-coarse:opacity-100`);c.title=`Renames the folder on disk; nothing is erased`,c.onclick=()=>void x(e.slug);let l=o(`flex-none cursor-pointer text-[11.5px] text-neutral-400 opacity-0 transition-opacity hover:text-indigo-600 group-hover:opacity-100 pointer-coarse:opacity-100`,()=>`${location.origin}${p(e)}`);l.title=`Copy the board's link`,a.append(u(`div`,`ml-auto flex flex-none items-center gap-2`,s,l,c)),t.append(a);let d=u(`div`,`flex flex-wrap items-center gap-x-3 gap-y-1 text-[11.5px] text-neutral-500`);d.append(u(`span`,`min-w-0 break-all font-mono text-neutral-400`,e.slug)),e.description&&d.append(u(`span`,`min-w-0 truncate`,e.description)),d.append(u(`span`,`flex-none text-neutral-400`,n(Date.parse(e.updatedAt))));for(let t of e.sessions){let e=f(t.slice(0,8),`flex-none cursor-pointer font-mono text-indigo-600 hover:underline`);e.title=`Open session ${t}`,e.onclick=()=>h(t),d.append(e)}if(e.public&&e.token){let t=u(`span`,`min-w-0 break-all font-mono text-neutral-400`,p(e));t.title=`Public URL — anyone holding this link can read the board, no password`,d.append(t)}return t.append(d),t}let C=[{title:`Public`,hint:`Readable at /p/<slug>-<token>/ by anyone holding the link.`,wanted:!0},{title:`Private`,hint:`Reachable from the Console only.`,wanted:!1}];function w(){let n=u(`div`,`mx-auto flex w-full max-w-3xl flex-col gap-4`);g.length===0&&n.append(e(`No boards yet. Ask an agent to build one — it writes a folder under ~/.pier/boards.`));for(let{title:e,hint:r,wanted:i}of C){let a=g.filter(e=>e.public===i);a.length!==0&&n.append(t(e,r,u(`div`,`flex flex-col divide-y divide-neutral-200/70`,...a.map(S))))}y.replaceChildren(..._?[u(`p`,`mb-3 text-[13px] text-red-600`,_)]:[],n)}async function T(){let e=await r(`/api/boards`,`Could not load boards`);if(!e.ok)return _=e.error,w();g=e.value,w(),_=``}let E=a(m,()=>void T());return window.addEventListener(`focus`,()=>{E.visible&&T()}),E}export{m as createBoardsView};
@@ -1,4 +0,0 @@
1
- import{$ as e,D as t,G as n,H as r,J as i,U as a,a as o,at as s,dt as c,f as l,ft as u,g as d,h as f,j as p,l as m,m as ee,ot as te,p as h,q as g,u as ne,v as re,w as ie,y as _}from"./index-T-vLXPch.js";var ae=[[`path`,{d:`M12 5v14`}],[`path`,{d:`m19 12-7 7-7-7`}]],oe=[[`path`,{d:`M12 22v-6`}],[`path`,{d:`M12 8V2`}],[`path`,{d:`M4 12H2`}],[`path`,{d:`M10 12H8`}],[`path`,{d:`M16 12h-2`}],[`path`,{d:`M22 12h-2`}],[`path`,{d:`m15 19-3-3-3 3`}],[`path`,{d:`m15 5-3 3-3-3`}]],se=[[`path`,{d:`M10 20a1 1 0 0 0 .553.895l2 1A1 1 0 0 0 14 21v-7a2 2 0 0 1 .517-1.341L21.74 4.67A1 1 0 0 0 21 3H3a1 1 0 0 0-.742 1.67l7.225 7.989A2 2 0 0 1 10 14z`}]],ce=[[`path`,{d:`M15 6a9 9 0 0 0-9 9V3`}],[`circle`,{cx:`18`,cy:`6`,r:`3`}],[`circle`,{cx:`6`,cy:`18`,r:`3`}]],v=[[`path`,{d:`M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8`}],[`path`,{d:`M3 3v5h5`}]],y=e=>{let t=new Date(e),n=e=>String(e).padStart(2,`0`);return`${t.getFullYear()}-${n(t.getMonth()+1)}-${n(t.getDate())} ${n(t.getHours())}:${n(t.getMinutes())}:${n(t.getSeconds())}`},le=e=>[e.subject,e.body,[[e.author,e.email?`<${e.email}>`:``].filter(Boolean).join(` `),e.at?y(e.at):``].filter(Boolean).join(` · `)].filter(Boolean).join(`
2
-
3
- `),b=null;function x(){b&&(b.remove(),b=null,document.removeEventListener(`pointerdown`,x,!0),window.removeEventListener(`scroll`,x,!0))}function ue(e,t){e.onmouseenter=()=>{x();let n=t();if(!n)return;b=g(`div`,`pointer-events-none fixed z-50 max-w-96 whitespace-pre-wrap rounded-lg bg-neutral-800/95 px-3 py-2 font-sans text-[11.5px] leading-snug text-neutral-100 shadow-lg`),b.textContent=n,document.body.append(b),document.addEventListener(`pointerdown`,x,!0),window.addEventListener(`scroll`,x,!0);let r=e.getBoundingClientRect();b.style.left=`${Math.max(8,Math.min(r.left,window.innerWidth-b.offsetWidth-8))}px`,b.style.top=r.bottom+6+b.offsetHeight>window.innerHeight?`${r.top-b.offsetHeight-6}px`:`${r.bottom+6}px`},e.onmouseleave=x}function S(e,t,n){let r=g(`button`,`flex w-full cursor-pointer items-center gap-1.5 px-2 py-1 text-left hover:bg-neutral-100`,s(c,`h-3 w-3 text-indigo-600 ${e.value===t?``:`invisible`}`),g(`span`,`inline-flex flex-none items-center gap-1.5 font-mono text-[11.5px] text-neutral-800`,e.label??e.value));return e.subject&&r.append(g(`span`,`min-w-0 truncate text-[11px] text-neutral-400`,e.subject)),e.author&&r.append(g(`span`,`ml-auto flex-none pl-1.5 text-[10.5px] text-neutral-400`,`${e.author} · ${e.at?i(e.at):``}`)),ue(r,()=>le(e)),r.onclick=()=>n(e.value),r}function de(e,t,n,r,i){let{base:a,head:o}=n,c=()=>o&&a===`${o}~1`?o:``,u=c()?`commit`:`compare`,d=t.commits[0],f=e=>[...e?[{value:``,label:`Working tree`,subject:`the files on disk, uncommitted changes included`}]:[],{...d,value:`HEAD`},...t.refs.map(e=>({value:e.name,subject:e.subject})),...t.commits.map(e=>({...e,value:e.hash}))],p=g(`div`,`flex w-[38rem] max-w-full flex-col`);function m(){let e=(e,t)=>{let n=g(`button`,`cursor-pointer rounded-full px-2.5 py-0.5 text-[11.5px] ${u===t?`bg-indigo-50 font-medium text-indigo-700`:`text-neutral-500 hover:bg-neutral-100`}`,e);return n.onclick=()=>{u=t,m()},n},n=(e,t,n,r)=>g(`div`,`flex min-w-0 flex-1 flex-col`,g(`div`,`flex-none px-2 pb-0.5 text-[10.5px] font-semibold uppercase tracking-wide text-neutral-400`,e),g(`div`,`min-h-0 flex-1 overflow-y-auto`,...t.map(e=>S(e,n,r)))),l=u===`compare`?g(`div`,`flex h-72 min-h-0 divide-x divide-neutral-200 pb-1`,n(`base`,f(!1),a,e=>{a=e,m(),r(a,o)}),n(`to`,f(!0),o,e=>{o=e,m(),r(a,o)})):g(`div`,`flex h-72 min-h-0 flex-col overflow-y-auto pb-1`,...t.commits.map(e=>S({value:e.hash,...e},c(),e=>{a=`${e}~1`,o=e,ne(),r(a,o)}))),d=g(`div`,`flex-none border-t border-neutral-200`,S({value:i,label:`${i} ↔ Working tree`,subject:`back to the default: the main line's tip vs the files on disk`},a===i&&o===``?i:``,()=>{a=i,o=``,m(),r(a,o)}));d.querySelector(`button > span`).prepend(s(v)),p.replaceChildren(g(`div`,`flex flex-none items-center gap-1 border-b border-neutral-200 px-2 py-1.5`,e(`Compare two points`,`compare`),e(`One commit`,`commit`)),l,...u===`compare`?[d]:[])}m(),l(e,p)}var fe=/\.(png|jpe?g|gif|webp|avif|bmp|svg)$/i,C={A:`text-emerald-600`,M:`text-amber-600`,D:`text-red-600`},pe=30,w=`pier.filesPrefs`,T=50,E=()=>{try{let e=JSON.parse(localStorage.getItem(w)??`{}`);return e&&typeof e==`object`?e:{}}catch{return{}}},me=e=>{let t=e?E()[e]:void 0;return typeof t?.cwd==`string`&&typeof t.base==`string`&&typeof t.head==`string`?{cwd:t.cwd,base:t.base,head:t.head}:null},he=(e,t)=>{let n=E();delete n[e],n[e]=t;let r=Object.keys(n);for(let e of r.slice(0,Math.max(0,r.length-T)))delete n[e];try{localStorage.setItem(w,JSON.stringify(n))}catch(e){console.warn(`Files: could not remember this folder`,e)}},ge=`flex min-w-0 flex-none items-center gap-1.5 rounded-full px-2.5 py-1 font-mono text-[11.5px]`,_e=(e,t=`indigo`)=>ie(e,`${ge} cursor-pointer truncate text-left ${t===`indigo`?`bg-indigo-50 font-medium text-indigo-700 hover:bg-indigo-100`:`bg-neutral-100 text-neutral-600 hover:bg-neutral-200`}`);function D(i,c,l,ne){let v=``,y=``,b={branch:null,refs:[],commits:[],worktrees:[]},x=`HEAD`,S=``,w=new Map,T=!0,E=!1,D=[],O=new Set,k=null,A=null,j=null,ve=g(`header`,`pagehead flex-nowrap`),M=g(`div`,`${d} flex-col items-stretch gap-1.5 px-3 py-2.5`),N=g(`div`,`min-h-0 flex-1 overflow-y-auto px-1.5 py-1.5`),ye=g(`aside`,`${_} w-64 flex-none text-[12.5px] leading-5 max-md:h-1/2 max-md:w-full`,M,N),P=g(`div`,`${d} flex-wrap gap-2 px-4 py-2.5 font-mono text-[11.5px] text-neutral-500`),F=g(`div`,`h-full min-w-0 overflow-auto`),I=g(`div`,`pointer-events-none absolute inset-y-1 right-0.5 z-20 hidden w-2`),be=g(`div`,`relative min-h-0 flex-1`,F,I),xe=g(`section`,`${_} min-h-0 min-w-0 flex-1`,P,be);i.append(ve,g(`div`,`flex min-h-0 flex-1 gap-3 px-4 pb-4 pt-1 max-md:flex-col`,ye,xe));let L=(e,t)=>`/api/explorer/${e}?${new URLSearchParams({root:v,...t})}`,R=(e,t)=>`/api/fs/${e}?${new URLSearchParams({root:v,...t})}`,z=(e,t=`text-neutral-400`)=>g(`p`,`px-3 py-2 text-[12.5px] ${t}`,e),B=e=>g(`div`,`flex h-full items-center justify-center p-6`,t(e)),V=()=>T&&b.branch!==null,Se=e=>{let t=0;for(let n of w.keys())n.startsWith(`${e}/`)&&t++;return t};function Ce(e,t){let n=new Set(t.map(e=>e.name)),r=e?`${e}/`:``;for(let[e,i]of w){if(i.status!==`D`||!e.startsWith(r))continue;let a=e.slice(r.length).split(`/`)[0];n.has(a)||(n.add(a),t.push({name:a,dir:e.slice(r.length).includes(`/`)}))}let i=t;return V()&&(i=t.filter(e=>e.dir?Se(`${r}${e.name}`)>0:w.has(`${r}${e.name}`))),i.sort((e,t)=>Number(t.dir)-Number(e.dir)||e.name.localeCompare(t.name)),i.map(e=>e.dir?Te(`${r}${e.name}`,e.name):Ee(`${r}${e.name}`,e.name))}async function we(t,n){try{let{entries:r}=await e(R(`ls`,{path:n}),`could not list this folder`);t.replaceChildren(...Ce(n,r)),t.childElementCount||t.append(z(V()?`No changes.`:`Empty.`))}catch(e){let r=Ce(n,[]);r.length?t.replaceChildren(...r):t.replaceChildren(z(String(e),`text-red-600`))}}function Te(e,t){let r=Se(e),i=[g(`span`,`truncate`,t)];r&&i.push(g(`span`,`ml-auto flex-none rounded-full bg-amber-100/80 px-1.5 font-mono text-[10px] font-semibold text-amber-700`,String(r)));let{el:a,summary:o}=n(``,i);o.className+=` rounded-lg px-2 py-1 transition-colors hover:bg-neutral-100`;let s=g(`div`,`pl-3`);a.append(s);let c=!1,l=()=>{c||(c=!0,s.append(z(`…`)),we(s,e))};return a.ontoggle=()=>{O[a.open?`add`:`delete`](e),a.open&&l()},((b.branch?T:!e.includes(`/`))&&!E||O.has(e))&&(a.open=!0,l()),a}function H(e){A?.classList.remove(`bg-indigo-50`,`font-medium`,`text-indigo-700`),A=e,e.classList.add(`bg-indigo-50`,`font-medium`,`text-indigo-700`)}function Ee(e,t){let n=w.get(e)?.status,r=g(`button`,`flex w-full cursor-pointer items-center gap-1.5 rounded-lg py-1 pl-6 pr-2 text-left transition-colors hover:bg-neutral-100`,g(`span`,`truncate`,t));return n&&r.append(g(`span`,`ml-auto flex-none font-mono text-[10.5px] font-semibold ${C[n]??`text-neutral-500`}`,n)),r.title=e,e===k&&H(r),e===j&&(j=null,k=e,H(r),J(e)),r.onclick=()=>{H(r),k=e,J(e)},r}let U=()=>void we(N,``),De=(e,t)=>[...e?[g(`span`,`flex-none font-mono text-[10.5px] font-semibold text-emerald-600`,`+${e}`)]:[],...t?[g(`span`,`flex-none font-mono text-[10.5px] font-semibold text-red-600`,`−${t}`)]:[]];function W(e,t=!1,n){if(P.classList.toggle(`hidden`,!e),P.classList.toggle(`flex`,!!e),!e)return P.replaceChildren();let i=w.get(e),a=[g(`span`,`min-w-0 truncate`,e)];i&&(a.push(g(`span`,`flex-none font-semibold ${C[i.status]??``}`,i.status)),a.push(...De(i.add,i.del)));let o=g(`div`,`ml-auto flex flex-none items-center gap-2`);if(n&&o.append(n),t){let t=document.createElement(`a`);t.className=`btn flex-none text-[12px]`,t.href=R(`file`,{path:e}),t.download=r(e),t.textContent=`Download`,o.append(t)}P.replaceChildren(...a,o)}function Oe(e){let t=0;for(;t<e.length;){if(e[t].tone!==`del`){t++;continue}let n=t;for(;t<e.length&&e[t].tone===`del`;)t++;let r=t;for(;t<e.length&&e[t].tone===`add`;)t++;let i=Math.min(r-n,t-r);for(let t=0;t<i;t++){let i=e[n+t],a=e[r+t],o=Math.min(i.text.length,a.text.length),s=0;for(;s<o&&i.text[s]===a.text[s];)s++;let c=0;for(;c<o-s&&i.text[i.text.length-1-c]===a.text[a.text.length-1-c];)c++;s+c!==0&&(i.mark=[s,i.text.length-c],a.mark=[s,a.text.length-c])}}}let G=Ie();function K(){I.classList.add(`hidden`),I.replaceChildren(),G.set([],[])}let ke=0,q=e=>e===ke;function J(e){let t=++ke;return w.has(e)?Le(e,t):Ae(e,t)}async function Ae(e,t){if(!q(t))return;K();let n=R(`file`,{path:e}),r=g(`div`,`min-w-0`);if(F.classList.remove(`flex`,`flex-col`),W(e,!0),F.replaceChildren(r),fe.test(e)){let e=g(`img`,`max-w-full p-4`);e.src=n,e.onerror=()=>r.replaceChildren(z(`Could not load image.`,`text-red-600`)),r.append(e);return}if(e.toLowerCase().endsWith(`.pdf`)){let e=document.createElement(`iframe`);e.className=`h-full w-full`,e.src=n,F.replaceChildren(e),F.classList.add(`flex`,`flex-col`);return}r.append(z(`…`));let i=await fetch(n);if(!q(t))return;if(!i.ok){let{error:e}=await i.json().catch(()=>({}));r.replaceChildren(z(e??`HTTP ${i.status}`,`text-red-600`));return}if(!(i.headers.get(`content-type`)??``).startsWith(`text/plain`)){r.replaceChildren(z(`Binary file — use Download.`));return}let a=ee(await i.text());if(!q(t))return;let o=await f(e);q(t)&&r.replaceChildren(h(a,o))}function je(e){let t=[],n=0,r=0,i=!1,a=e.split(`
4
- `);a.at(-1)===``&&a.pop();for(let e of a){let a=/^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(e);if(a){i&&t.push({nums:[``,``],text:`⋯`,tone:``}),[n,r]=[Number(a[1]),Number(a[2])],i=!0;continue}!i||e.startsWith(`\\`)||(e.startsWith(`+`)?t.push({nums:[``,r++],text:e.slice(1),tone:`add`}):e.startsWith(`-`)?t.push({nums:[n++,``],text:e.slice(1),tone:`del`}):t.push({nums:[n++,r++],text:e.slice(1),tone:``}))}return t}function Me(e){let t=[];for(let n=0;n<e.length;n++){let r=e[n].tone;if(!r)continue;let i=t.at(-1);i&&i.end===n-1?(i.end=n,(r===`add`&&i.tone===`del`||r===`del`&&i.tone===`add`)&&(i.tone=`mixed`)):t.push({start:n,end:n,tone:r})}return t}let Ne={add:`bg-emerald-400`,del:`bg-red-400`,mixed:`bg-amber-400`};function Pe(e,t,n){I.replaceChildren(...e.map(e=>{let r=g(`button`,`map-mark pointer-events-auto absolute left-1/2 w-1 -translate-x-1/2 cursor-pointer rounded-full ${Ne[e.tone]}`);return r.style.top=`${e.start/t*100}%`,r.style.height=`${Math.max(.4,(e.end-e.start+1)/t*100)}%`,r.title=`line ${Fe(n,e.start)}`,r.onclick=()=>n[e.start].scrollIntoView({block:`center`}),r})),I.classList.remove(`hidden`)}let Fe=(e,t)=>e[t]?.querySelector(`span`)?.textContent?.trim()||String(t+1);function Ie(){let e=[],t=[],n=-1,r=g(`span`,`flex-none text-[10.5px] text-neutral-400`),a=i=>{e.length&&(n=(n+i+e.length)%e.length,t[e[n].start].scrollIntoView({block:`center`}),r.textContent=`${n+1}/${e.length}`)},c=()=>e.length>0&&!i.classList.contains(`hidden`),l=(e,t,n,r)=>{let i=g(`button`,`icon-btn flex-none`,s(e));return i.onclick=()=>a(t),o(i,r,n,()=>a(t),c),i};return{el:g(`div`,`flex flex-none items-center gap-1`,r,l(u,-1,`Previous change`,[`p`,`k`]),l(ae,1,`Next change`,[`n`,`j`])),set(i,a){e=i,t=a,n=-1,r.textContent=`${i.length} change${i.length===1?``:`s`}`}}}async function Le(t,n){K(),F.classList.remove(`flex`,`flex-col`);let r=w.get(t)?.status!==`D`;W(t,r),F.replaceChildren(z(`…`));try{let{diff:i}=await e(L(`diff`,{base:x,head:S,file:t,context:`99999`}),`could not read this diff`);if(!q(n))return;if(!i||/^Binary files /m.test(i))return Ae(t,n);let a=je(i);Oe(a);let o=await f(t);if(!q(n))return;let s=h(a,o),c=[...s.children],l=Me(a);G.set(l,c),W(t,r,G.el),F.replaceChildren(s),Pe(l,a.length,c)}catch(e){if(!q(n))return;W(t),F.replaceChildren(z(String(e),`text-red-600`))}}let Y=()=>S&&x===`${S}~1`?S:``,Re=()=>b.commits.find(e=>e.hash===Y())?.subject??``,ze=()=>Y()?`${Y()} — ${Re()||`commit`}`:`${x} ↔ ${S||`working tree`}`,Be=(e,t,n,r)=>{let i=g(`button`,`icon-btn ${n?`bg-indigo-50 text-indigo-700 hover:bg-indigo-100 hover:text-indigo-700`:``}`);return i.append(s(e)),i.title=t,i.setAttribute(`aria-label`,t),i.setAttribute(`aria-pressed`,String(n)),i.onclick=r,i};function X(){let e=Be(se,`Show only files that differ`,T,()=>{T=!T,X(),U()}),t=Be(oe,E?`Restore folders`:`Collapse all folders`,E,()=>{if(E=!E,E)D=[...O],O.clear();else for(let e of D)O.add(e);X(),U()});if(!b.branch){M.replaceChildren(g(`div`,`flex items-center gap-1.5`,g(`span`,`min-w-0 flex-1 text-[11px] leading-snug text-neutral-400`,`Not a git repository — nothing to compare.`),t));return}let n=ie(ze(),`${re} font-mono`);if(Y()){let e=b.commits.find(e=>e.hash===Y());ue(n,()=>e?le(e):``)}else n.title=`Choose what to diff`;n.onclick=async()=>{await We(),de(n,b,{base:x,head:S},(e,t)=>{x=e,S=t,Z()},$())};let[r,i]=[0,0];for(let e of w.values())r+=e.add,i+=e.del;M.replaceChildren(n,g(`div`,`flex items-center gap-1.5 pl-0.5`,g(`span`,`text-[11px] text-neutral-400`,`${w.size} changed file${w.size===1?``:`s`}`),...De(r,i),g(`span`,`ml-auto`),e,t))}let Ve=()=>{y&&v&&he(y,{cwd:v,base:x,head:S})},He=e=>{let t=e.replace(/~\d+$/,``);return!t||t===`HEAD`||b.refs.some(e=>e.name===t)||b.commits.some(e=>e.hash===t)};async function Ue(){if(w=new Map,!b.branch)return;let{files:t}=await e(L(`diff`,{base:x,head:S}),`could not read what changed`);for(let e of t)w.set(e.path,{status:e.status,add:e.add,del:e.del});if(w.size<=pe)for(let e of w.keys()){let t=e.split(`/`);for(let e=1;e<t.length;e++)O.add(t.slice(0,e).join(`/`))}}async function Z(){Ve();try{await Ue()}catch(e){X(),N.replaceChildren(z(String(e),`text-red-600`));return}X(),U(),k&&J(k)}function Q(){let e=_e(``,`neutral`);e.className+=` max-w-72`,e.append(g(`span`,`truncate max-md:hidden`,v||`Choose a folder…`),g(`span`,`truncate md:hidden`,v?r(v):`Choose a folder…`)),e.title=v?`${v} — switch folder`:`Choose a folder`,e.onclick=()=>m(e,b.worktrees.map(e=>({path:e.path,...e.branch?{hint:e.branch}:{}})),v||void 0,l);let t=g(`button`,`icon-btn ml-auto`,s(te));t.type=`button`,t.title=`Close Files`,t.setAttribute(`aria-label`,`Close Files`),t.onclick=ne;let n=b.branch?[g(`span`,`${ge} bg-neutral-100 text-neutral-600`,s(ce),g(`span`,`truncate`,b.branch))]:[];ve.replaceChildren(p(`Files`),e,...n,t)}async function We(){if(v)try{b=await e(L(`git`,{}),`could not read this repository`),Q()}catch(e){F.replaceChildren(B(String(e)))}}let $=()=>b.refs.some(e=>e.name===`main`)?`main`:b.refs.some(e=>e.name===`master`)?`master`:`HEAD`;async function Ge(t){if(Q(),k=null,A=null,O.clear(),j=t||null,t){T=!1;let e=t.split(`/`);for(let t=1;t<e.length;t++)O.add(e.slice(0,t).join(`/`))}if(K(),F.classList.remove(`flex`,`flex-col`),W(),F.replaceChildren(B(`Select a file.`)),!v){M.replaceChildren(),N.replaceChildren(),F.replaceChildren(B(`No folder yet — pick one from the chip above.`));return}try{b=await e(L(`git`,{}),`could not read this repository`)}catch(e){b={branch:null,refs:[],commits:[],worktrees:[]},F.replaceChildren(B(String(e)))}let n=me(y),r=n&&n.cwd===v&&He(n.base)&&He(n.head)?n:null;x=r?.base??$(),S=r?.head??``,Q(),await Z()}return a(i,(e,t)=>{let n=c(),r=n?.id??``,i=new URLSearchParams(t).get(`select`)??void 0,a=e?.startsWith(`/`)?e:(r===y?v:``)||me(r)?.cwd||n?.cwd||``;if(a===v&&r===y&&N.childElementCount&&(!i||i===k)){Q(),We().then(Z);return}y=r,v=a,Ge(i)},()=>{K(),F.classList.remove(`flex`,`flex-col`),W(),F.replaceChildren(B(`Select a file.`))})}export{D as createExplorerView};