Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d39586de5 | ||
|
|
eaa92738dc | ||
|
|
b2349525fd | ||
|
|
898209725a | ||
|
|
1fe767ea77 | ||
|
|
43f32a7440 | ||
|
|
cdbf72c7fe | ||
|
|
ab2336516f | ||
|
|
9f243f569f | ||
|
|
160788780b | ||
|
|
41f54a292b | ||
|
|
ae70dd5ea8 | ||
|
|
060033322d | ||
|
|
c97da0292a | ||
|
|
d2c07a9fd2 | ||
|
|
537cc29c3e |
@@ -0,0 +1,114 @@
|
||||
# 2026.08.30.01 — rollout report
|
||||
|
||||
**7 of 7 targets succeeded. `officer-dev` skipped by design — it is the host the rollout ran on.**
|
||||
|
||||
One plugin failed on one host (`fofos-admin` on `fofos`) and it is a false alarm in the checker, not a
|
||||
broken install. The owner fixed it mid-rollout in `537cc29c`, which is NOT in this tag. Detail below.
|
||||
|
||||
No schema change, no `bun install`, no `bun db:push` — the plan says so and shows the two
|
||||
`git diff --name-only` commands behind the claim. Nothing was improvised.
|
||||
|
||||
## Per host
|
||||
|
||||
| Host | HEAD before | HEAD after | plugins:update | `officer` restart_time | /api/health | file-browser `ls` |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| `officer-dev` | — | — | — | — | — | **SKIPPED — ran here** |
|
||||
| `alpha` | `e9b71522` | `816f8695` | 15/15, exit 0 | 6 → 7 | 401 | 200, 77 entries |
|
||||
| `fofos` | `601849be` | `816f8695` | **4/5, exit 1** | 3 → 4 | 401 | 200, 39 entries |
|
||||
| `macbookpro` | `e9b71522` | `816f8695` | no plugins installed | 6 → 7 | 401 | 200, 62 entries |
|
||||
| `macbruno` | `e9b71522` | `816f8695` | 1/1, exit 0 | 9 → 10 | 401 | 200, 41 entries |
|
||||
| `production-pertento-bridge` | `e9b71522` | `816f8695` | 1/1, exit 0 | 7 → 8 | 401 | 200, 53 entries |
|
||||
| `edge-pertento-bridge` | `e9b71522` | `816f8695` | 2/2, exit 0 | 8 → 9 | 401 | 200, 29 entries |
|
||||
| `deadpool-rezio-bridge` | `e9b71522` | `816f8695` | 2/2, exit 0 | 25 → 26 | 401 | 200, 49 entries |
|
||||
|
||||
Every host landed on `816f8695`, exactly the tag. `fofos` started ahead of the previous tag and behind
|
||||
this one, as the plan predicted, so its fast-forward moved 12 files rather than 23.
|
||||
|
||||
`401` on `/api/health` is healthy — the plan says so and all seven agreed. Ports were read from each
|
||||
host's own `.env`, never assumed: 9010 on `macbookpro` and `production-pertento-bridge`, 9000 elsewhere.
|
||||
|
||||
## The file browser check
|
||||
|
||||
This release touched the listing loader, the clipboard and the row markup, so the plan asked for one
|
||||
signed `/api/file-browser/ls` per host. **Seven hosts, seven `200`s**, every one with a plausible count
|
||||
and none of them zero. The counts differ because the homes differ.
|
||||
|
||||
Signed as that host's own owner, read from its own `users` table rather than assumed —
|
||||
`conceiba@gmail.com` on `macbruno`, `brunorezio@gmail.com` on `deadpool-rezio-bridge`,
|
||||
`pastilhas@officer.dev` on the rest.
|
||||
|
||||
## Schema
|
||||
|
||||
**No schema verification was performed, because this release changes no schema.** No `db:push` step
|
||||
exists in the plan and none was run, so there is nothing to verify by query. The clipboard is a key in
|
||||
`user_state`, a JSONB column that already existed.
|
||||
|
||||
The plugin sidecars applied their OWN schemas during `plugins:update`, which belongs to the plugin
|
||||
updater and not to this release: `music` on `alpha` (4 tables), `proxy-manager` on `fofos` (3) and on
|
||||
`macbruno` (3), `offscale` on `edge-pertento-bridge` (1), `email` on `deadpool-rezio-bridge` (1).
|
||||
|
||||
## The one failure: `fofos-admin` on `fofos`
|
||||
|
||||
`bun run plugins:update` exited 1 with:
|
||||
|
||||
```
|
||||
FAILED: packages installed, but @tiptap/pm cannot be resolved from …/plugins/fofos-admin
|
||||
```
|
||||
|
||||
**The install is fine and the plugin is not broken.** Verified on the host rather than assumed:
|
||||
|
||||
- `plugins/node_modules/@tiptap/pm` is present, with its `dist` and the prosemirror packages.
|
||||
- `@tiptap/pm` declares no `main`, no `module`, and an `exports` map with no `"."` — it exists only as
|
||||
subpaths. Code imports `@tiptap/pm/state`, never the bare name.
|
||||
- `Bun.resolveSync('@tiptap/pm', pluginDir)` therefore fails, while `@tiptap/react` and
|
||||
`@tiptap/starter-kit` both resolve. `unresolvableDependencies` reads that as a failed install.
|
||||
- `fofos-admin`'s own checkout is clean, so this is not the usual uncommitted-change cause.
|
||||
|
||||
Also verified there, and it is this release working as intended: `react` resolves out of the PLATFORM's
|
||||
`node_modules`, not the plugins tree. The eviction added in `9719e367` did its job.
|
||||
|
||||
**The owner shipped the fix at 11:20 while the rollout was in flight — `537cc29c`, "a package with no
|
||||
root entry point is not a broken install".** It is one commit PAST this tag and is therefore deployed
|
||||
nowhere. `fofos` will keep failing that step on every `plugins:update` until a release carries it.
|
||||
|
||||
What this cost on `fofos`: `fofos-admin` did not finish its update — no assets publish, no access grant,
|
||||
no `recorded at`. Its code was already at the latest commit, and the platform mounted `/fofos-admin`
|
||||
normally. The other four plugins updated.
|
||||
|
||||
## A commit landed on master mid-rollout, and the tag absorbed it
|
||||
|
||||
`alpha`, `fofos`, `macbookpro`, `macbruno` and `production-pertento-bridge` fetched `origin/master` at
|
||||
`816f8695`. `edge-pertento-bridge` and `deadpool-rezio-bridge` fetched it at `537cc29c`.
|
||||
|
||||
Because every host merged `--ff-only deploy/2026.08.30.01` and not the branch, **all seven are on the
|
||||
tag and none carries the extra commit.** This is the exact failure of 2026-08-23, and merging the tag is
|
||||
what prevented it.
|
||||
|
||||
## More restarted than `officer`
|
||||
|
||||
`plugins:update` restarts a sidecar whenever it moves a plugin, before the plan's own restart. Naming
|
||||
only `officer` would understate what moved, so: `officer-music` on `alpha`, `officer-av-club` on
|
||||
`fofos`, `officer-gitea` on `production-pertento-bridge`, `officer-gitea` and `officer-offscale` on
|
||||
`edge-pertento-bridge`, `officer-email` on `deadpool-rezio-bridge`. `macbruno` moved `proxy-manager`,
|
||||
which declares no sidecar, so nothing extra bounced there.
|
||||
|
||||
After every restart, `pm2 jlist` showed **every `officer*` process `online` on all seven hosts**. None
|
||||
was `errored` and none failed to come back.
|
||||
|
||||
`ecosystem.config.cjs` was never restarted anywhere. No vault was touched. On
|
||||
`production-pertento-bridge` the eight Pertento-owned processes — `Dashboard`, `Tracking`, `Uptime` and
|
||||
the five `Crons *` — were filtered out of every read and never named in a restart.
|
||||
|
||||
## What I did not do
|
||||
|
||||
- **`officer-dev` — skipped entirely.** Not pulled, not restarted. The rollout ran inside its
|
||||
`officer-claude-code` sidecar, and restarting it would have ended the session writing this report. Its
|
||||
working tree already held this code. The owner restarts it deliberately, as the last step.
|
||||
- **Did not fix the `fofos-admin` checker.** The fix exists in `537cc29c` and belongs to a release, not
|
||||
to a rollout editing five hosts.
|
||||
- **Did not re-run `plugins:update` on `fofos`** after diagnosing it. It would fail identically — the
|
||||
code that decides is in the tag.
|
||||
- **Did not run `bun run docs:check`.** Not the commander's job.
|
||||
- **One untracked file noted, not touched:** `scripts/chk.ts` on `fofos`. Untracked is a note, not a
|
||||
skip, and a fast-forward could never have touched it. Every host was clean on
|
||||
`--untracked-files=no`.
|
||||
@@ -0,0 +1,99 @@
|
||||
# 2026.08.30.03 — a package with no root entry point is not a broken install
|
||||
|
||||
**One commit.** `537cc29c`, fixing what the 2026.08.30.01 rollout found on `fofos`.
|
||||
|
||||
**Why .03 and not .02.** `2026.08.30.02` was tagged against a commit that was not on `master` — the
|
||||
rollout's own report had just been pushed and the release was cut without pulling first. The branch push
|
||||
was rejected, the tag was not, and a tag pointing off-branch was published for about ten seconds before
|
||||
being withdrawn. The watcher claimed the version inside that window.
|
||||
|
||||
A claim is permanent by design and that is correct: `.02` was attempted, so `.02` is spent and cannot be
|
||||
deployed. It has a claim and no `.done`, which is exactly what "attempted and did not succeed" should
|
||||
look like in the record. Nothing reached any host — every one stayed on `816f8695` throughout.
|
||||
|
||||
**No schema change and no dependency change.** Verified rather than assumed:
|
||||
|
||||
```bash
|
||||
git diff --name-only deploy/2026.08.30.01..deploy/2026.08.30.03 | grep -iE 'schema|/db/|databases' # nothing
|
||||
git diff --name-only deploy/2026.08.30.01..deploy/2026.08.30.03 -- package.json bun.lock # nothing
|
||||
```
|
||||
|
||||
So **no `bun db:push` and no `bun install` step exists in this plan, and neither should be improvised.**
|
||||
|
||||
## What it fixes, and what it does not
|
||||
|
||||
`unresolvableDependencies` asked `Bun.resolveSync(name, pluginDir)` of every package a plugin declares.
|
||||
That is the right question for almost all of them and unanswerable for a few.
|
||||
|
||||
`@tiptap/pm` declares no `main`, no `module`, and an `exports` map with no `"."` — it exists only as
|
||||
subpaths, because it is tiptap's single pinned copy of the ProseMirror packages and you import
|
||||
`@tiptap/pm/state`, never `@tiptap/pm`. Asked to resolve the bare name, Bun correctly says it cannot,
|
||||
and the checker read that as a failed install.
|
||||
|
||||
**Nothing was ever broken on disk.** The install on `fofos` is complete — the directory, its `dist` and
|
||||
thirteen prosemirror packages. The editor works. What failed was the verification, which then failed
|
||||
`plugins:update`, which exits non-zero if any plugin failed.
|
||||
|
||||
The consequence worth shipping for: **`bun run plugins:update` exits 1 on `fofos` every time** until
|
||||
this lands, so the plugin step of every future deploy fails there and looks like a new problem.
|
||||
|
||||
The strong check stays first, so a package that installed but cannot be loaded on this machine still
|
||||
fails — `sharp` resolving to a platform-specific binary is the shape it was written for. Only a package
|
||||
that fails it is asked the weaker question: is it there.
|
||||
|
||||
## Every host — the commands
|
||||
|
||||
The standard steps in `prompts/deploy-commander.md` are the whole rollout (dirty check,
|
||||
`git fetch origin --tags`, `git merge --ff-only deploy/2026.08.30.03`, `bun run plugins:update`), plus:
|
||||
|
||||
```bash
|
||||
pm2 restart officer
|
||||
```
|
||||
|
||||
`officer` only — `plugins:update` has already restarted the plugin sidecars.
|
||||
|
||||
**`fofos` is the host this release is for.** Its `plugins:update` failed 4/5 on the last rollout and
|
||||
should now be 5/5, exit 0. That is the check that matters:
|
||||
|
||||
```
|
||||
→ fofos-admin
|
||||
packages: installed and resolvable
|
||||
```
|
||||
|
||||
If `fofos` still reports 4/5 with `@tiptap/pm cannot be resolved`, this release did not work and that
|
||||
is the finding — report it rather than working around it.
|
||||
|
||||
Everywhere else this is a no-op in behaviour: no other host has `fofos-admin` installed, so no other
|
||||
host was failing and none should start.
|
||||
|
||||
### Verify it came back
|
||||
|
||||
`pm2 jlist`: `officer` online and `restart_time` moved, then `/api/health` on the host's own port
|
||||
(9010 on `macbookpro` and `production-pertento-bridge`, 9000 elsewhere). `200` or `401` are healthy.
|
||||
|
||||
No file-browser check this time — this release touches one function in the plugin installer and
|
||||
nothing a browser reaches.
|
||||
|
||||
## Per host
|
||||
|
||||
Every host went to `816f8695` an hour ago and all seven came back online, so they start from there.
|
||||
|
||||
| Host | Note |
|
||||
| --- | --- |
|
||||
| `officer-dev` | **SKIP ENTIRELY.** The rollout runs here. It already holds this commit and its `officer` was restarted by hand at 11:23. |
|
||||
| `alpha` | Ordinary target. No `fofos-admin`, so nothing changes in behaviour. |
|
||||
| `fofos` | **The reason for this release.** Expect `plugins:update` to go 4/5 → 5/5. |
|
||||
| `macbookpro` | macOS, port **9010**, a laptop. Unreachable is a SKIP with a note, not a failure. |
|
||||
| `macbruno` | macOS, login **`brunoalcadapadezmonteirodaconceicao`**. |
|
||||
| `production-pertento-bridge` | Port **9010**. |
|
||||
| `edge-pertento-bridge` | Ordinary target. |
|
||||
| `deadpool-rezio-bridge` | Login **`br`**, checkout `/home/br/officerdev/platform`. |
|
||||
|
||||
Note for whoever reads the report: `scripts/chk.ts` is untracked on `fofos` and was noted last time. It
|
||||
is still a note, not a skip.
|
||||
|
||||
## What "done" looks like
|
||||
|
||||
Per host: HEAD before and after, `plugins:update` result, `officer` back online with a moved
|
||||
`restart_time`. And for `fofos` specifically, the `fofos-admin` line reading
|
||||
`packages: installed and resolvable`.
|
||||
@@ -0,0 +1,55 @@
|
||||
# 2026.08.30.03 — report
|
||||
|
||||
**7 of 7 target hosts succeeded.** `officer-dev` skipped entirely, as the host the rollout ran on.
|
||||
|
||||
Every target went `816f8695` → `c97da029` by `git merge --ff-only deploy/2026.08.30.03`. No host was
|
||||
dirty in tracked files, none had to be skipped, and no merge was anything other than a fast-forward.
|
||||
|
||||
**The release did what it was cut for.** `fofos` went 4/5 → **5/5, exit 0**, with `fofos-admin` reading
|
||||
`packages: installed and resolvable`. That was the one check that mattered and it passed.
|
||||
|
||||
**No schema step and no `bun install` step** — the plan has neither, and neither was improvised. So there
|
||||
is no per-host schema query in this report, because this release introduced nothing to query for.
|
||||
|
||||
## Per host
|
||||
|
||||
| Host | HEAD before → after | `plugins:update` | `officer` restart_time | health |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `officer-dev` | — | — | — | **SKIPPED** — the host this ran on |
|
||||
| `alpha` | `816f8695` → `c97da029` | 15/15 | 7 → 8, online | `401` (9000) |
|
||||
| `fofos` | `816f8695` → `c97da029` | **5/5, exit 0** | 4 → 5, online | `401` (9000) |
|
||||
| `macbookpro` | `816f8695` → `c97da029` | no plugins installed | 7 → 8, online | `401` (9010) |
|
||||
| `macbruno` | `816f8695` → `c97da029` | 1/1 | 10 → 11, online | `401` (9000) |
|
||||
| `production-pertento-bridge` | `816f8695` → `c97da029` | 1/1 | 8 → 9, online | `401` (9010) |
|
||||
| `edge-pertento-bridge` | `816f8695` → `c97da029` | 2/2 | 9 → 10, online | `401` (9000) |
|
||||
| `deadpool-rezio-bridge` | `816f8695` → `c97da029` | 2/2 | 26 → 27, online | `401` (9000) |
|
||||
|
||||
`401` on `/api/health` is healthy — the endpoint is behind auth.
|
||||
|
||||
## What is worth reading
|
||||
|
||||
- **`fofos` is fixed.** `plugins:update` there had failed 4/5 on every rollout since `@tiptap/pm` was
|
||||
installed, and it now exits 0. The plugin step of the next deploy should be unremarkable on that host.
|
||||
- **`macbookpro` needed a fetch retry.** The first `git fetch` failed with
|
||||
`Connection closed by 77.42.39.155 port 22` — gitea's public SSH, rate-limited at the proxy. That host
|
||||
resolves `gitea.officer.dev` to the public address where `alpha` reaches it over the tailnet. The first
|
||||
retry succeeded and the rollout continued normally. A later probe was refused again, so the window is
|
||||
intermittent rather than closed. **Nothing to fix in the deploy; the remote on that host is the thing
|
||||
that is wrong**, and pointing it at the tailnet address would remove a class of failure that costs a
|
||||
retry on every release.
|
||||
- **`scripts/chk.ts` is still untracked on `fofos`.** Noted, not a skip, exactly as the plan said. It has
|
||||
survived two rollouts now; somebody may want to commit it or delete it.
|
||||
- **`plugins:update` restarted sidecars on several hosts** before the `pm2 restart officer` the plan
|
||||
asked for — `officer-music` on `alpha`, `officer-gitea` on `production-pertento-bridge`,
|
||||
`officer-offscale` on `edge-pertento-bridge`, `officer-email` on `deadpool-rezio-bridge`. That is the
|
||||
documented behaviour of the plugins step, not something this rollout chose.
|
||||
- **On `production-pertento-bridge` only `officer` was restarted.** Pertento's eight production processes
|
||||
were not touched.
|
||||
|
||||
## What was not done
|
||||
|
||||
- `officer-dev` was not pulled, not updated and not restarted. It already holds `c97da029` and its
|
||||
`officer` was restarted by hand at 11:23. **The owner restarts this host deliberately, as the last
|
||||
step**, once happy with the rest.
|
||||
- No `bun db:push`, no `bun install`, no direct SQL. The plan called for none.
|
||||
- No file-browser check, per the plan.
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "officer",
|
||||
"version": "2026.08.30.01",
|
||||
"version": "2026.08.30.03",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
|
||||
@@ -97,20 +97,34 @@ export function App() {
|
||||
404 on a route that worked yesterday reads as the feature having been removed. */}
|
||||
<Route path="/plugins" element={<PluginsRedirect />} />
|
||||
{/* Installed plugins. Core routes above stay hand-written; everything below is generated from
|
||||
what is installed, because a bundler cannot follow a runtime import specifier. The wildcard
|
||||
hands the whole subtree to the plugin's own router, which react-router nests natively. */}
|
||||
{/* One pair per SCREEN, not per plugin — `screenId` is the key, because a plugin with two
|
||||
what is installed, because a bundler cannot follow a runtime import specifier. */}
|
||||
{/* One route per SCREEN, not per plugin — `screenId` is the key, because a plugin with two
|
||||
screens (caldav: Calendar and Contacts) contributes two entries sharing one `appName`. */}
|
||||
{/*
|
||||
Three routes, and the third is the only new one.
|
||||
|
||||
`/<route>` and `/<route>/:section` are what every plugin has always had, and fifteen of
|
||||
them read `useParams().section`. A splat ALONE replaced that for a day and took the
|
||||
parameter away from all of them at once — the screens still mounted and every one of them
|
||||
lost its selection, which is a worse failure than a 404 because nothing says so.
|
||||
|
||||
The splat is added for depth rather than instead of them: `/fofos-admin/layout/header` was
|
||||
not expressible before, and a plugin that wants its own subtree reads the location.
|
||||
React-router ranks a static or dynamic segment above a splat, so a plugin with one segment
|
||||
still gets `section` and only deeper paths fall through.
|
||||
*/}
|
||||
{installedPlugins.flatMap((plugin) => [
|
||||
<Route key={plugin.screenId} path={plugin.route} element={<Dashboard.PluginScreen {...plugin} />} />,
|
||||
// The section pair, exactly as the core screens do it (`/soulseek/:section`): the plugin's
|
||||
// panels read `useParams` themselves, so which section is open is the URL rather than state
|
||||
// passed between them.
|
||||
<Route
|
||||
key={`${plugin.screenId}-section`}
|
||||
path={`${plugin.route}/:section`}
|
||||
element={<Dashboard.PluginScreen {...plugin} />}
|
||||
/>,
|
||||
<Route
|
||||
key={`${plugin.screenId}-deep`}
|
||||
path={`${plugin.route}/*`}
|
||||
element={<Dashboard.PluginScreen {...plugin} />}
|
||||
/>,
|
||||
])}
|
||||
<Route path="/system-monitor" element={<Dashboard.SystemMonitorScreen />} />
|
||||
<Route path="/system-monitor/:scope" element={<Dashboard.SystemMonitorScreen />} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { proxyOverrideScript, rewriteAssetPaths, rewriteHtml } from './html-rewrite';
|
||||
import { proxyOverrideScript, rewriteAssetPaths, rewriteCss, rewriteHtml } from './html-rewrite';
|
||||
|
||||
const BASE = '/api/dev-proxy/3001';
|
||||
const attrs = (html: string) => [...html.matchAll(/(?:src|href)="([^"]+)"/g)].map((m) => m[1]!);
|
||||
@@ -58,6 +58,43 @@ describe('rewriteHtml', () => {
|
||||
expect(out.indexOf('<script>')).toBeLessThan(out.indexOf('<title>'));
|
||||
});
|
||||
|
||||
test('every candidate in a srcset is prefixed, descriptors intact', () => {
|
||||
expect(rewriteHtml('<img srcset="/a.webp 96w, /b.webp 320w">', BASE)).toContain(
|
||||
`srcset="${BASE}/a.webp 96w, ${BASE}/b.webp 320w"`,
|
||||
);
|
||||
});
|
||||
|
||||
/*
|
||||
The failure this was found by. An <img> with a srcset also has a src, and the src WAS prefixed — but
|
||||
a browser given a valid srcset picks from it and never reads src. So the logo was missing while the
|
||||
markup looked right.
|
||||
*/
|
||||
test('a srcset is chosen over src by the browser, so prefixing src alone is not enough', () => {
|
||||
const out = rewriteHtml('<img src="/logo.webp" srcset="/logo.webp 96w">', BASE);
|
||||
expect(out).toContain(`src="${BASE}/logo.webp"`);
|
||||
expect(out).toContain(`srcset="${BASE}/logo.webp 96w"`);
|
||||
});
|
||||
|
||||
test('a candidate with no descriptor is prefixed too', () => {
|
||||
expect(rewriteHtml('<img srcset="/only.webp">', BASE)).toContain(`srcset="${BASE}/only.webp"`);
|
||||
});
|
||||
|
||||
test('imagesrcset on a preload link is prefixed — the one that is easy to miss', () => {
|
||||
expect(rewriteHtml('<link rel="preload" as="image" imagesrcset="/hero.webp 1280w">', BASE)).toContain(
|
||||
`imagesrcset="${BASE}/hero.webp 1280w"`,
|
||||
);
|
||||
});
|
||||
|
||||
test('a protocol-relative candidate is left alone, like everywhere else here', () => {
|
||||
expect(rewriteHtml('<img srcset="//cdn.example.com/a.webp 96w, /b.webp 320w">', BASE)).toContain(
|
||||
`srcset="//cdn.example.com/a.webp 96w, ${BASE}/b.webp 320w"`,
|
||||
);
|
||||
});
|
||||
|
||||
test('`..` is collapsed in a srcset candidate as well', () => {
|
||||
expect(rewriteHtml('<img srcset="/../../x.webp 96w">', BASE)).toContain(`srcset="${BASE}/x.webp 96w"`);
|
||||
});
|
||||
|
||||
test('a query string and hash survive normalisation', () => {
|
||||
expect(attrs(rewriteHtml('<script src="/../x.js?v=2#frag">', BASE))).toEqual(['/api/dev-proxy/3001/x.js?v=2#frag']);
|
||||
});
|
||||
@@ -175,3 +212,42 @@ describe('the injected location reporter', () => {
|
||||
expect(r.posted).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stylesheets', () => {
|
||||
test('a quoted url is prefixed — the form every generator emits', () => {
|
||||
expect(rewriteCss('@font-face{src:url("/fonts/Averta.woff2") format("woff2")}', BASE)).toContain(
|
||||
`url("${BASE}/fonts/Averta.woff2")`,
|
||||
);
|
||||
});
|
||||
|
||||
test('single quotes and bare urls too', () => {
|
||||
expect(rewriteCss("a{background:url('/bg.png')}", BASE)).toContain(`url('${BASE}/bg.png')`);
|
||||
expect(rewriteCss('a{background:url(/bg.png)}', BASE)).toContain(`url(${BASE}/bg.png)`);
|
||||
});
|
||||
|
||||
test('the quote style is preserved, not normalised', () => {
|
||||
expect(rewriteCss('a{background:url("/bg.png")}', BASE)).not.toContain("url('");
|
||||
});
|
||||
|
||||
test('a url that is already absolute is left alone', () => {
|
||||
const external = 'a{background:url("https://cdn.example.com/bg.png")}';
|
||||
expect(rewriteCss(external, BASE)).toBe(external);
|
||||
const protocolRelative = 'a{background:url("//cdn.example.com/bg.png")}';
|
||||
expect(rewriteCss(protocolRelative, BASE)).toBe(protocolRelative);
|
||||
});
|
||||
|
||||
test('a relative url is left alone — it already resolves against the stylesheet', () => {
|
||||
const relative = 'a{background:url("./bg.png")}';
|
||||
expect(rewriteCss(relative, BASE)).toBe(relative);
|
||||
});
|
||||
|
||||
test('a stylesheet still gets the /_bun/ rule', () => {
|
||||
expect(rewriteCss('@import "/_bun/x.css";', BASE)).toContain(`${BASE}/_bun/x.css`);
|
||||
});
|
||||
|
||||
test('inline CSS in HTML gets the same treatment, quotes included', () => {
|
||||
expect(rewriteHtml('<div style=\'background:url("/a.png")\'></div>', BASE)).toContain(
|
||||
`url("${BASE}/a.png")`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -89,7 +89,38 @@ const normalisePath = (path: string): string => {
|
||||
/** `/x` → `<base>/x`, with `..` already collapsed. Never touches `//host` or a full URL. */
|
||||
const prefix = (base: string, path: string): string => `${base}${normalisePath(path)}`;
|
||||
|
||||
// HTML: rewrite src/href/action attributes with absolute paths + inject fetch/XHR override
|
||||
/**
|
||||
* A `srcset`, which is a LIST and cannot be prefixed as one value.
|
||||
*
|
||||
* `srcset="/a.webp 96w, /b.webp 2x"` — comma-separated candidates, each a URL plus an optional
|
||||
* descriptor. The attribute rule below matches a whole attribute value, so it can only ever have
|
||||
* rewritten the first URL and left the descriptor mangled onto it; it does not match `srcset` at all,
|
||||
* which is why responsive images 404'd silently.
|
||||
*
|
||||
* Silently is the operative word. A `<picture>` or an `<img srcset>` still has a `src`, and that one WAS
|
||||
* prefixed — but a browser given a valid srcset chooses from it and never looks at src. So the markup
|
||||
* appeared correct, one attribute pointed at the platform root, and the image was simply missing.
|
||||
*
|
||||
* Split on commas rather than parsed: a URL cannot contain an unescaped comma in this position, and the
|
||||
* descriptor never does.
|
||||
*/
|
||||
const prefixSrcset = (base: string, list: string): string =>
|
||||
list
|
||||
.split(',')
|
||||
.map((candidate) => {
|
||||
const trimmed = candidate.trim();
|
||||
if (!trimmed) return '';
|
||||
// The URL is everything up to the first space; the rest is `96w`, `2x`, or nothing.
|
||||
const space = trimmed.search(/\s/);
|
||||
const url = space === -1 ? trimmed : trimmed.slice(0, space);
|
||||
const descriptor = space === -1 ? '' : trimmed.slice(space);
|
||||
// Same rule as everywhere else here: root-absolute only, never protocol-relative.
|
||||
return url.startsWith('/') && !url.startsWith('//') ? `${prefix(base, url)}${descriptor}` : trimmed;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
|
||||
// HTML: rewrite src/href/action/srcset attributes with absolute paths + inject fetch/XHR override
|
||||
export const rewriteHtml = (html: string, base: string) =>
|
||||
html
|
||||
.replace(/<head([^>]*)>/i, `<head$1>${proxyOverrideScript(base)}`)
|
||||
@@ -97,12 +128,52 @@ export const rewriteHtml = (html: string, base: string) =>
|
||||
/(href|src|action)=("|')(\/(?!\/)[^"']*)\2/gi,
|
||||
(_match, attr: string, quote: string, path: string) => `${attr}=${quote}${prefix(base, path)}${quote}`,
|
||||
)
|
||||
.replace(/url\((\/(?!\/)[^)"']*)\)/g, (_match, path: string) => `url(${prefix(base, path)})`);
|
||||
/*
|
||||
`srcset` and `imagesrcset`, which the attribute rule above cannot do — see prefixSrcset.
|
||||
|
||||
// JS/CSS: only `/_bun/` paths.
|
||||
`imagesrcset` is the one on `<link rel="preload" as="image">`. It is easy to miss and expensive
|
||||
to miss: a preload that 404s costs a request AND leaves the real image undiscovered until the
|
||||
body is parsed, which is the opposite of what the preload was added for.
|
||||
*/
|
||||
.replace(
|
||||
/(imagesrcset|srcset)=("|')([^"']*)\2/gi,
|
||||
(_match, attr: string, quote: string, list: string) => `${attr}=${quote}${prefixSrcset(base, list)}${quote}`,
|
||||
)
|
||||
.replace(CSS_URL, cssUrlPrefixer(base));
|
||||
|
||||
/*
|
||||
`url(/…)` — a font, a background image, an `@import`. CSS allows the target bare, single-quoted or
|
||||
double-quoted, and all three mean the same thing, so all three have to match. The quote used is
|
||||
captured and put back rather than normalised: rewriting is not the place to have an opinion about
|
||||
someone else's stylesheet.
|
||||
|
||||
This rule missed every QUOTED url until 2026-08-31, which is a bad way to fail. `url(/x.png)` was
|
||||
rewritten and `url("/x.png")` was not, so whether a stylesheet worked through the proxy came down to
|
||||
the formatting preference of whatever generated it.
|
||||
*/
|
||||
const CSS_URL = /url\(\s*(["']?)(\/(?!\/)[^)"']*)\1\s*\)/g;
|
||||
|
||||
const cssUrlPrefixer =
|
||||
(base: string) =>
|
||||
(_match: string, quote: string, path: string): string =>
|
||||
`url(${quote}${prefix(base, path)}${quote})`;
|
||||
|
||||
// JS: only `/_bun/` paths.
|
||||
//
|
||||
// Deliberately narrow. A blanket rewrite of every absolute path inside JavaScript hits strings that are
|
||||
// not URLs — a regex in the source, a route table, anything containing a slash — and corrupts the file it
|
||||
// is trying to fix. Bun's dev server emits its chunk imports under `/_bun/`, which is specific enough to
|
||||
// match safely. A Vite app instead wants `base` set in its own config; nothing here can guess that.
|
||||
export const rewriteAssetPaths = (text: string, base: string) => text.replace(/\/_bun\//g, `${base}/_bun/`);
|
||||
|
||||
/*
|
||||
A STYLESHEET gets more than that, because a stylesheet's absolute paths are unambiguous: `url(/…)` is
|
||||
a URL and nothing else, so the argument for being narrow in JavaScript does not apply here.
|
||||
|
||||
This was the whole of it for a long time, and the symptom was specific: a site's own `@font-face` rules
|
||||
kept pointing at `/fonts/…`, which inside the frame is the PLATFORM's root, so every face 404'd and the
|
||||
preview quietly rendered in a system fallback. Nothing looked broken — it just looked like a different
|
||||
site than the one being previewed, which is the one thing a preview must never do.
|
||||
*/
|
||||
export const rewriteCss = (text: string, base: string) =>
|
||||
rewriteAssetPaths(text, base).replace(CSS_URL, cssUrlPrefixer(base));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { resolveAuthToken } from '../../auth-token';
|
||||
import { isSuperAdmin } from '../../super-admin';
|
||||
import { rewriteAssetPaths, rewriteHtml } from './html-rewrite';
|
||||
import { rewriteAssetPaths, rewriteCss, rewriteHtml } from './html-rewrite';
|
||||
|
||||
/**
|
||||
* See a dev server running on THIS machine's loopback, from a browser that is somewhere else.
|
||||
@@ -145,7 +145,8 @@ devProxyRouter.all('/:port/*', async (ctx) => {
|
||||
|
||||
const type = res.headers.get('content-type') ?? '';
|
||||
const isHtml = type.includes('text/html');
|
||||
const rewritable = isHtml || type.includes('javascript') || type.includes('text/css');
|
||||
const isCss = type.includes('text/css');
|
||||
const rewritable = isHtml || isCss || type.includes('javascript');
|
||||
|
||||
const headers = new Headers(res.headers);
|
||||
// Anything framed here is the owner's own dev server; the platform frames it deliberately.
|
||||
@@ -169,7 +170,11 @@ devProxyRouter.all('/:port/*', async (ctx) => {
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
const body = isHtml ? rewriteHtml(text, prefix) : rewriteAssetPaths(text, prefix);
|
||||
const body = isHtml
|
||||
? rewriteHtml(text, prefix)
|
||||
: isCss
|
||||
? rewriteCss(text, prefix)
|
||||
: rewriteAssetPaths(text, prefix);
|
||||
// Both are wrong after a rewrite: the length changed, and the body was decoded on the way through.
|
||||
headers.delete('content-length');
|
||||
headers.delete('content-encoding');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createRouter } from '@@/create-router';
|
||||
import { resolve, dirname, join, sep, parse as parsePath } from 'node:path';
|
||||
import { readdir, stat, mkdir, rm, rename, readFile, cp } from 'node:fs/promises';
|
||||
import { readdir, lstat, readlink, stat, mkdir, rm, rename, readFile, cp } from 'node:fs/promises';
|
||||
import type { Dirent } from 'node:fs';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { getOwnerHomeDir, DATA_PATH } from '@@/data-path';
|
||||
import { resolveHomeDir } from '@@/user-home';
|
||||
@@ -188,9 +189,17 @@ router.get('/ls', async (ctx) => {
|
||||
await cleanOldCacheDirs(rootDir);
|
||||
}
|
||||
|
||||
let names: string[];
|
||||
/*
|
||||
`withFileTypes`, so a symlink can be spotted without a second syscall per entry.
|
||||
|
||||
The kind comes off the directory entry itself on every filesystem that reports it, and where it
|
||||
does not Node falls back to an lstat it would otherwise have had to make anyway. `stat` below
|
||||
FOLLOWS the link — which is right, and is why it cannot answer this question: a symlink to a folder
|
||||
must still say `directory` so that opening it works.
|
||||
*/
|
||||
let names: Dirent[];
|
||||
try {
|
||||
names = await readdir(absPath);
|
||||
names = await readdir(absPath, { withFileTypes: true });
|
||||
} catch (ex) {
|
||||
// A missing directory resets the browser to the root, which is the right answer for a stale path.
|
||||
//
|
||||
@@ -213,18 +222,44 @@ router.get('/ls', async (ctx) => {
|
||||
const claimed = await pluginFileTypes();
|
||||
|
||||
const entries = await Promise.all(
|
||||
names.map(async (name) => {
|
||||
names.map(async (entry) => {
|
||||
const name = entry.name;
|
||||
const fullPath = resolve(absPath, name);
|
||||
// Skip entries that escape the home dir (shouldn't happen but be safe)
|
||||
if (!isInside(rootDir, fullPath)) return null;
|
||||
|
||||
/*
|
||||
Where a link POINTS, exactly as it was written.
|
||||
|
||||
Not resolved to an absolute path: a relative target is a fact about the link, and rewriting it
|
||||
would hide that `../originals` moves with the folder while `/home/…/originals` does not. Only
|
||||
asked for the entries that are links, which in an ordinary folder is none of them.
|
||||
*/
|
||||
const target = entry.isSymbolicLink() ? await readlink(fullPath).catch(() => null) : null;
|
||||
|
||||
const s = await stat(fullPath).catch(() => null);
|
||||
if (!s) return null;
|
||||
const isDirectory = s.isDirectory();
|
||||
|
||||
/*
|
||||
A BROKEN link still gets a row, and did not before.
|
||||
|
||||
`stat` follows, so a link whose target is gone fails here and the entry returned null — the file
|
||||
vanished from the listing entirely. Nothing said it was there, nothing could delete it, and the
|
||||
folder simply had fewer things in it than `ls` showed. `lstat` describes the link itself, which
|
||||
exists whatever it points at.
|
||||
*/
|
||||
const own = s ?? (target !== null ? await lstat(fullPath).catch(() => null) : null);
|
||||
if (!own) return null;
|
||||
|
||||
const isDirectory = s?.isDirectory() ?? false;
|
||||
return {
|
||||
name,
|
||||
type: isDirectory ? 'directory' : 'file',
|
||||
size: s.size,
|
||||
modifiedAt: s.mtimeMs,
|
||||
size: own.size,
|
||||
modifiedAt: own.mtimeMs,
|
||||
/** Where it points, or absent when it is an ordinary file. See the note above. */
|
||||
symlink: target,
|
||||
/** A link whose target is gone. `type` is `file` for want of anything truer to say. */
|
||||
broken: target !== null && s === null ? true : undefined,
|
||||
/*
|
||||
Whether `/thumb` will produce something, decided HERE so there is one list of extensions
|
||||
rather than one on each side of the wire.
|
||||
|
||||
@@ -181,12 +181,31 @@ const body = {
|
||||
export function unresolvableDependencies(plugin: DiscoveredPlugin): string[] {
|
||||
const names = Object.keys(plugin.manifest.npmDependencies ?? {});
|
||||
return names.filter((name) => {
|
||||
// The strong check: it imports. This is what catches a package that installed but cannot be loaded
|
||||
// on this machine — `sharp` resolving to a platform-specific binary is the shape it was written for.
|
||||
try {
|
||||
Bun.resolveSync(name, plugin.dir);
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
// fall through
|
||||
}
|
||||
|
||||
/*
|
||||
Some packages have NO root entry point, and that is not a fault.
|
||||
|
||||
`@tiptap/pm` declares no `main`, no `module`, and an `exports` map with no `"."` — it exists only as
|
||||
subpaths, and you import `@tiptap/pm/state`, never `@tiptap/pm`. Asked to resolve the bare name,
|
||||
Bun correctly says it cannot, and the check above read that as a broken install.
|
||||
|
||||
It cost a deploy: fofos-admin declares `@tiptap/pm` because tiptap needs it present, the install
|
||||
was perfect — the directory, its dist and thirteen prosemirror packages all there — and the plugin
|
||||
was reported as failed on every host that had it.
|
||||
|
||||
So a package that does not resolve is asked the weaker question instead: is it THERE. Present but
|
||||
unimportable still fails, because the strong check ran first and only a package with no root entry
|
||||
reaches this line.
|
||||
*/
|
||||
return !existsSync(join(PLUGINS_DIR, 'node_modules', name, 'package.json'));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -29,12 +29,18 @@ import { join } from 'node:path';
|
||||
* is that tag, this checkout is at the latest release. If it is a different one, the remote has moved on.
|
||||
* That holds for any tag names anyone ever chooses, including ones that do not sort.
|
||||
*
|
||||
* ── What this does NOT do ──
|
||||
* ── The answer is never awaited on the network ──
|
||||
*
|
||||
* It never fetches. Every answer comes from refs already on disk, because this is called once per plugin
|
||||
* on a request that renders a list — twenty-one network round trips would make the plugins screen feel
|
||||
* broken. Tags arrive at clone and at the fetch `pullPlugin` already does, so "check for updates" is a
|
||||
* consequence of updating rather than a separate poll.
|
||||
* Every answer comes from refs already on disk. This is called once per plugin on a request that renders
|
||||
* a list, and twenty-one network round trips would make the plugins screen feel broken.
|
||||
*
|
||||
* But refs on disk go stale, and for a long time nothing refreshed them: tags arrived at clone and at
|
||||
* the fetch `pullPlugin` does, so you only learned an update existed BY UPDATING. A machine that had not
|
||||
* updated a plugin since August compared this morning's release against a snapshot from before it
|
||||
* existed and said, truthfully and uselessly, that it was at the latest.
|
||||
*
|
||||
* So `refreshRemote` runs a fetch in the background and this request answers from what is there now. The
|
||||
* NEXT read is current. Rate-limited per checkout and run one at a time — see the note on it.
|
||||
*/
|
||||
|
||||
export type ReleaseState = {
|
||||
@@ -47,6 +53,58 @@ export type ReleaseState = {
|
||||
reason: 'untagged' | 'new-release' | 'at-latest' | 'not-a-checkout';
|
||||
};
|
||||
|
||||
/**
|
||||
* A background `git fetch --tags`, at most one per checkout per REFRESH_MS.
|
||||
*
|
||||
* ── Not awaited, on purpose ──
|
||||
*
|
||||
* The caller is rendering a list. Waiting would put the network on the critical path of a page that has
|
||||
* to feel instant, which is the reason this file never fetched in the first place. The request answers
|
||||
* from the refs it has; the fetch it starts is for the next one.
|
||||
*
|
||||
* The cost of that is one stale view after a release, corrected by a refresh. The alternative — never
|
||||
* fetching — is a badge that is wrong until somebody updates for an unrelated reason.
|
||||
*
|
||||
* ── One at a time ──
|
||||
*
|
||||
* Twenty-one plugins would otherwise spawn twenty-one gits at once, every ten minutes, on machines that
|
||||
* are not all fast. Chained rather than pooled because there is no hurry: nothing is waiting on it.
|
||||
*
|
||||
* Failure is silence. A checkout with no remote, an unreachable host, a repository that has moved — none
|
||||
* of those should surface as anything, because the list still has a true answer without them.
|
||||
*/
|
||||
/*
|
||||
Two minutes, and the number is chosen for how releases are actually watched.
|
||||
|
||||
It was ten. The way this gets used is: ship a plugin, open the page, look for the badge — and ten
|
||||
minutes of that is indistinguishable from the feature not working. A release tagged at 12:42 was
|
||||
invisible at 12:45 because the last fetch happened at 12:37, which is correct behaviour and no comfort.
|
||||
|
||||
The cost is bounded by the page being open: nothing fetches when nobody is looking, and the fetches are
|
||||
chained, so the worst case is one small round trip per plugin every two minutes while somebody watches
|
||||
the plugins screen.
|
||||
*/
|
||||
const REFRESH_MS = 2 * 60 * 1000;
|
||||
const fetchedAt = new Map<string, number>();
|
||||
let queue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
export function refreshRemote(dir: string): void {
|
||||
const last = fetchedAt.get(dir) ?? 0;
|
||||
if (Date.now() - last < REFRESH_MS) return;
|
||||
// Stamped BEFORE the fetch runs, so a slow one cannot be started again by the next twenty requests.
|
||||
fetchedAt.set(dir, Date.now());
|
||||
|
||||
queue = queue.then(async () => {
|
||||
const proc = Bun.spawn(['git', '-C', dir, 'fetch', '--tags', '--quiet', 'origin'], {
|
||||
stdout: 'ignore',
|
||||
stderr: 'ignore',
|
||||
// A host that hangs must not hold the queue: every plugin behind it would stop refreshing too.
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
});
|
||||
await proc.exited.catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
const run = async (dir: string, args: string[]): Promise<string | null> => {
|
||||
const proc = Bun.spawn(['git', '-C', dir, ...args], { stdout: 'pipe', stderr: 'ignore' });
|
||||
const out = await new Response(proc.stdout).text();
|
||||
@@ -61,6 +119,9 @@ export async function releaseState(dir: string): Promise<ReleaseState> {
|
||||
return { ...none, updateable: false, reason: 'not-a-checkout' };
|
||||
}
|
||||
|
||||
// Started, not awaited: this answer comes from the refs on disk and the fetch is for the next one.
|
||||
refreshRemote(dir);
|
||||
|
||||
// `--exact-match`, so this is "the tag AT this commit", not "the last tag before it". Fails, and
|
||||
// therefore returns null, when HEAD is between releases — which is the first half of the rule.
|
||||
const tagAtHead = await run(dir, ['describe', '--exact-match', '--tags', 'HEAD']);
|
||||
|
||||
+40
-2
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Folder,
|
||||
Link2,
|
||||
Trash2,
|
||||
Pencil,
|
||||
MoreVertical,
|
||||
@@ -631,6 +632,21 @@ export const FileItem = ({
|
||||
const clickTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const isDir = entry.type === 'directory';
|
||||
|
||||
/*
|
||||
A link, and where it goes.
|
||||
|
||||
Drawn as an arrow beside the name rather than as a badge or a different icon: the icon already says
|
||||
what the thing IS — a folder, a photograph — and a symlink to a folder is still a folder to anyone
|
||||
using it. What the arrow adds is that there is an indirection in the way, which is the thing that
|
||||
explains why deleting it did not free any space, or why two places show the same file.
|
||||
|
||||
The target goes in `title`. It is often long and absolute, so it belongs in a tooltip rather than in
|
||||
a row that has to fit a name, a type, a size and a date.
|
||||
*/
|
||||
const linkLabel = entry.symlink
|
||||
? `${entry.broken ? 'Broken symlink' : 'Symlink'} → ${entry.symlink}`
|
||||
: undefined;
|
||||
|
||||
const icon = isDir ? (
|
||||
<Folder className="h-5 w-5 text-duck-yellow fill-duck-yellow/30" />
|
||||
) : (
|
||||
@@ -820,7 +836,18 @@ export const FileItem = ({
|
||||
onCancel={() => setRenaming(false)}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-duck-dark truncate block">{entry.name}</span>
|
||||
<span className="flex min-w-0 items-center gap-1">
|
||||
<span className="truncate text-sm font-medium text-duck-dark">{entry.name}</span>
|
||||
{linkLabel && (
|
||||
<span
|
||||
title={linkLabel}
|
||||
aria-label={linkLabel}
|
||||
className={`shrink-0 leading-none ${entry.broken ? 'text-destructive' : 'text-duck-dark/45'}`}
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="hidden md:inline text-xs text-duck-dark/50 shrink-0 w-20 text-right">
|
||||
@@ -886,7 +913,18 @@ export const FileItem = ({
|
||||
onCancel={() => setRenaming(false)}
|
||||
/>
|
||||
) : (
|
||||
<span className="w-full truncate text-center text-sm font-medium text-duck-dark">{entry.name}</span>
|
||||
<span className="flex w-full min-w-0 items-center justify-center gap-1">
|
||||
<span className="truncate text-center text-sm font-medium text-duck-dark">{entry.name}</span>
|
||||
{linkLabel && (
|
||||
<span
|
||||
title={linkLabel}
|
||||
aria-label={linkLabel}
|
||||
className={`shrink-0 leading-none ${entry.broken ? 'text-destructive' : 'text-duck-dark/45'}`}
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="text-xs text-duck-dark/50">
|
||||
|
||||
+18
-2
@@ -213,7 +213,11 @@ export const VideoDownloadDialog = ({ fileBrowserManager }: VideoDownloadDialogP
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
// `min-w-0` on the DIRECT grid child. DialogContent is a grid, and a grid item defaults to
|
||||
// `min-width: auto` — so this is the element that grows to fit a long title and takes the
|
||||
// card out of the dialog with it. The constraint has to be here; on the scroll container
|
||||
// inside it was one level too low to stop anything.
|
||||
<div className="flex min-w-0 flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@@ -248,7 +252,19 @@ export const VideoDownloadDialog = ({ fileBrowserManager }: VideoDownloadDialogP
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex max-h-[52vh] flex-col gap-2 overflow-y-auto pr-1">
|
||||
{/*
|
||||
`min-w-0` is the one that makes `truncate` work, three levels down.
|
||||
|
||||
DialogContent is a GRID, and a grid item defaults to `min-width: auto` — it refuses to
|
||||
shrink below its content. So a long title pushed this container wider than the dialog's
|
||||
own `max-w-lg`, the card grew with it, and the `truncate` on the title never had a width
|
||||
to truncate against. The text simply ran out of the modal.
|
||||
|
||||
Making the card a column fixed a different half of this and was not enough: a column still
|
||||
cannot be narrower than the widest thing a grid will let it be. The constraint has to come
|
||||
from above, and this is the highest point where it was missing.
|
||||
*/}
|
||||
<div className="flex max-h-[52vh] min-w-0 flex-col gap-2 overflow-y-auto pr-1">
|
||||
{entries.map((e, i) => (
|
||||
<div
|
||||
key={`${e.url}-${i}`}
|
||||
|
||||
@@ -205,6 +205,20 @@ export type DirEntry = {
|
||||
* response, which simply means no thumbnail — the file icon, exactly as before.
|
||||
*/
|
||||
thumb?: 'image' | 'video' | null;
|
||||
/**
|
||||
* Where this points, when it is a symlink — exactly as the link was written.
|
||||
*
|
||||
* Relative targets stay relative. `../originals` and `/home/…/originals` behave differently when the
|
||||
* folder moves, and resolving one into the other would hide the difference at the moment somebody is
|
||||
* looking to understand it.
|
||||
*
|
||||
* `type` still describes the TARGET, so a link to a folder opens like a folder. This says only that
|
||||
* there is a link in the way. Absent on an older response, which reads as "not a link" — the same
|
||||
* thing every entry said before.
|
||||
*/
|
||||
symlink?: string | null;
|
||||
/** The target is gone. The row is drawn anyway, because the link itself is really there. */
|
||||
broken?: boolean;
|
||||
};
|
||||
|
||||
type ListDirResponse = {
|
||||
|
||||
@@ -2,6 +2,15 @@ export { useSettings, DEFAULT_SETTINGS } from './useSettings';
|
||||
export type { UseSettingsType, UserSettings, UserState } from './useSettings';
|
||||
export { useUserState } from './useUserState';
|
||||
export { useDashboardState } from './useDashboardState';
|
||||
/*
|
||||
Exported for a workspace nested INSIDE a panel — see plugins/fofos-admin/web/WorkPanel.tsx.
|
||||
|
||||
`useDashboardState` persists the whole tree, which is right for a dashboard somebody arranged and
|
||||
wrong for a layout its author ships: the structure would be frozen at whatever it was the first time
|
||||
a screen was opened. `useAuthoredLayout` keeps the structure in source and persists only sizes and
|
||||
zoom, which is what a nested authored layout wants for the same reason `PluginScreen` does.
|
||||
*/
|
||||
export { useAuthoredLayout } from './useAuthoredLayout';
|
||||
export { useModels, useVisibleModels, useUserVisibleModels, useEnabledModels, modelKey } from './useModels';
|
||||
export type { ModelOption } from './useModels';
|
||||
export { useAccessPolicy } from './useAccessPolicy';
|
||||
|
||||
@@ -204,6 +204,51 @@
|
||||
background-attachment: fixed;
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
/*
|
||||
NATIVE CONTROLS FOLLOW THE THEME.
|
||||
------------------------------------------------------------------------------------------
|
||||
|
||||
Nothing here set `color-scheme`, so the browser drew every control it renders itself as if the page
|
||||
were light — on a dark theme, a `<select>`'s dropdown opened as a white sheet with the page's own
|
||||
pale text on it. Unreadable, and unreachable by any utility class, because that list is painted by
|
||||
the browser and is not in the document.
|
||||
|
||||
`color-scheme` is the only thing that reaches it. It also fixes, for free, the other controls with
|
||||
no styleable interior: date and colour pickers, the spinner on a number input, and default
|
||||
scrollbars where a theme has not replaced them.
|
||||
|
||||
── Why the `dark` class decides it ──
|
||||
|
||||
It is the signal that exists. 203 of the 231 themes set `applyDarkClass`, and the provider puts the
|
||||
class on <html> beside `data-theme`, so the two are always in agreement about the same element.
|
||||
Both are specificity 0-1-0 and themes are imported after this file, so a theme that disagrees can
|
||||
simply say so — this is a default, not a decision taken away.
|
||||
|
||||
── And the explicit colours underneath ──
|
||||
|
||||
`color-scheme` gives the browser's generic dark, not the theme's. Where a browser honours colours on
|
||||
`<option>` — Firefox, and Chrome on Linux and Windows — these make the list the theme's own popover
|
||||
surface instead. Where it does not (Safari, Chrome on macOS, which use a genuinely native menu), the
|
||||
scheme above is what carries it, and generic dark is a great deal better than white-on-white.
|
||||
|
||||
A utility still wins: these are base-layer rules, so `bg-transparent` on a select behaves exactly as
|
||||
it did.
|
||||
*/
|
||||
:root {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
select,
|
||||
option,
|
||||
optgroup {
|
||||
background-color: var(--popover);
|
||||
color: var(--popover-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
Reference in New Issue
Block a user