Compare commits

...
Author SHA1 Message Date
Reece 997b6c03de refactor(files): tidy the windowing hook and its story
The row window returned through a useMemo whose dependency is rebuilt every render,
so the cache never hit; the values behind it are a couple of multiplications.

VirtualFileRows is only its own return type, so it stops being exported, and the
empty-state story stands up its own New folder control now that the page owns it.
2026-09-02 20:14:50 +01:00
Reece 4a57b6ad56 Merge remote-tracking branch 'origin/main' into files-grid-perf
# Conflicts:
#	frontend/editor/src/core/components/filesPage/FileGrid.tsx
#	frontend/editor/src/core/components/filesPage/FileManagerView.tsx
#	frontend/editor/src/desktop/services/localFolderContents.ts
2026-09-02 19:55:58 +01:00
Reece a349afe9c1 fix(files): folder history, one New folder control, and open-once
Walking into a folder wrote its path with replace, so the whole journey shared one
history entry: Back did not step up a folder, it left the library and landed on
whatever came before it. Each folder is its own entry now, and the two effects that
keep path and selection in step carry a marker so neither overwrites the entry the
other just arrived at. A path naming a folder that has not loaded yet waits for the
folder map to fill instead of falling back to the root.

New folder is one control in both places it appears. The empty state offered a
single click that guessed a destination and blocked itself where it could not; it
now shows the header's menu, under the header's label.

Opening a file already in the workspace skips the fetch-and-add and just goes to
it, and a folder answers to the source filter the way its files do.
2026-09-02 17:21:25 +01:00
James BruntonandEthanHealy01 aca0e40c37 Combine Policies and Pipelines pages (#7681)
# Description of Changes
Combine the Policies and Pipelines pages into one, so we have the new
concept of Policies as Pipelines that always run which the user cannot
disable. What used to be Policies are now referred to as Templates, and
they allow you to create a new Pipeline more easily with the simple UI.

There's followup work to be done here to improve the template UIs
because they've not been touched in a long time, but I've considered
that beyond the scope of this merge. The only real changes I've made to
them in this PR is that they have a toggle for whether they're policies,
they now have a "Customise" button to kick you into the full Pipeline
editor, and I've removed the source selection. Previously, they
supported selecting as many sources as you liked, but that feature never
worked and is incompatible with the backend as it stands now, which only
allows for one source. Because of that, I've made it so that they can
only run in editor unless you open them in the custom pipeline editor,
where you can switch out which source it will use.

There's also another bit of followup to rename and remove all the
previous Policies code. Now that they've been combined into one, we
don't need a lot of the Policies code anymore, but also there's about
300 files in the frontend referencing policies in text/comments which
need to be updated to say pipelines. This is way more work than is
reasonable to do in this PR so I'll just do it in a new PR.

## Limitations
This PR is about the merging of the old Policies and Pipelines and I'm
considering enforcing the new definition of a Policy where it's only
modifiable by admins beyond the scope of this PR.

<img width="756" height="395" alt="image"
src="https://github.com/user-attachments/assets/d31be5ce-f1c9-46b3-8e8d-866e63f89a81"
/>

<img width="1507" height="793" alt="image"
src="https://github.com/user-attachments/assets/9ba8875f-8be5-4881-91cf-40e0bc1076dc"
/>

<img width="1508" height="787" alt="image"
src="https://github.com/user-attachments/assets/3e1da77b-a0c0-4262-aad3-16650098db81"
/>

---------

Co-authored-by: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com>
2026-09-02 16:08:15 +00:00
Reece Browne 1b2a3118a6 Disk-mounted folders on desktop and improved folder management (#7502)
Description of Changes

Adds folder kinds so the file manager can work with real directories on
disk.

Desktop
- New folder is now a menu with two options: "Add local folder" and "New
folder on the server".
- Add local folder opens the native picker and mounts a directory. Files
are listed straight from disk, nothing is copied in.
- Subfolders show inside a mount and open like any folder. New folder
inside a mount creates a real directory on disk.
- Moving, dropping or uploading files into a mount writes them to the
directory. The app copy is only removed after the write succeeds. Name
clashes get a " (2)" suffix.
- Mounted files get thumbnails.
- Adding the same directory twice just returns the existing mount.
- Removing a mount never touches the disk.
- The server option is disabled in local mode with a sign in message.

Web + desktop
- Uploading or dropping files while inside a folder puts them in that
folder instead of Local.
- Files can be dragged onto folders in the grid and the tree to move
them.
- Folders show an origin badge (cloud or local).
- The Local view now means files that are not in any folder.

Follow ups for a future pr
- Mount listing cap: large directories currently show the 500 most
recent files with no notice. Will be removed as part of the
virtualisation/performance PR.
- Folders within folders need to be supported
- Symlinks in mounts: currently not listed. Behaviour to be decided
alongside the wider folder work.
2026-09-02 14:18:57 +00:00
ConnorYoh 3056e5ff44 Reset the PAYG free grant each billing period (#7709)
Needs the schema half: Stirling-Tools/Stirling-PDF-SaaS#327

## Current state

The PAYG free allowance is a one-time lifetime pool.
`pricing_policy.free_tier_units` is copied into
`payg_team_extensions.free_units_remaining` once, at team creation (V14
trigger, updated in V19), and the charge pipeline decrements it until it
reaches zero. Nothing ever puts it back.

## Problem

The product promises a monthly allowance the billing model does not
grant.

- The account-link connect dialog advertises "500 free per month". That
has **merged to main** (#7415), so the claim is live and unhonoured
until this lands.
- The wallet meter already read "Process 500 PDFs free, then $X/PDF",
which reads as an allowance-then-meter model.
- `SignupRequiredBootstrap`'s own doc comment described a "free
500-op/month allowance" while its copy said only "500 free operations".

Three separate comments asserted the opposite in code
(`billing/types.ts`, `WalletSnapshotResponse`, `TeamBillingContext`), so
the two halves of the repo disagreed about what a customer is owed.

## Solution

The grant now recurs each billing period, **for every team**. Paying
does not cost you the allowance: a subscribed team draws its grant first
each period and meters only the excess, which is what the meter's copy
always described. That also matches how the grant already worked at
charge time, where it reduced metered units regardless of subscription.

### The reset is lazy, with no scheduler

`payg_team_extensions` gains `free_units_period_start`: the period
`free_units_remaining` was last written for.

- A stamp older than the current period start, or absent as on every
existing row, means the reset is owed.
`TeamBillingService.remainingForPeriod` projects it to a full grant, so
the entitlement gate and the wallet both show it the instant the period
turns.
- `JobChargeService.consumeFreeGrant` persists it on the next charge,
under the pessimistic row lock that already makes the per-job free/paid
split exact.

One rule, both callers, so display and enforcement cannot drift onto
separate schedules. A team that runs nothing for a month has nothing to
write, and no job is needed to hand out the grant.

### One period definition

"Per period" is `TeamBillingContext.periodStart`: the Stripe
subscription's current period when subscribed, the calendar month
otherwise. It was already the only period notion in the system, so the
grant joined it rather than inventing its own:

- `InstanceEntitlement.periodCapUnits` is enforced over the same window.
- `localUsageService.currentPeriodUnsynced` already buckets a linked
instance's local usage by the `periodStart` it reads from the same
snapshot, and resets its counters on that boundary.

For an un-subscribed team, the only kind the grant gates, that window is
the calendar month, which is what the copy promises.

The period rule stays in Java by choice, not necessity: SQL could reach
the Stripe period through the sync engine, but restating the rule there
would give it a second home to drift from. Hence a nullable column and
no backfill in the migration — NULL already means "stale", so every
existing team reads as owed the current period's grant.

### Refunds

A refund landing after the period turned would have stacked last
period's units on top of the fresh grant.
`JobChargeService.restoreFreeGrant` now clamps the restore to one
period's grant, taking the same row lock, and the bulk-increment
`restoreFreeUnits` query is gone. Removing it also removed a `@Query`
string that no test would have parsed before application startup.

### Copy and comments

Every comment and user-facing string that asserted the lifetime model is
corrected. The strings that changed (code defaults and `en-US` TOML
updated together):

| Key | Now reads |
| --- | --- |
| `portal.billing.walletMeter.title` / `titleWithRate` | "500 free
credits every month, then $X per PDF" |
| `portal.billing.walletMeter.capSuffix` / `barAria` | "of 500 free
credits left this month" / "Free credits remaining" |
| `payg.free.hero.capSuffix` | "of 500 free PDFs left this month" |
| `plan.freeLimit.message` | "...this month. ... It resets next month,
or keep the momentum going now..." |
| `payg.signupRequired.body` | "500 free operations a month" |

Main rewrote these keys to "500 free credits to start" while this branch
was open. The merge keeps main's credits vocabulary and drops "to
start", which asserts the one-time grant this branch removes and which
main's own connect dialog already contradicts.

Also fixed in passing: `testing/compose/payg/saas-seed.sql` still
inserted `free_tier_units_per_cycle`, the pre-V19 column name, so that
INSERT had been failing since the rename.

## How to test

Backend:

```bash
STIRLING_FLAVOR=saas ./gradlew :saas:test spotlessCheck
```

Frontend:

```bash
task frontend:typecheck && task frontend:lint && task frontend:format:check
```

New coverage, 10 tests:

- `TeamBillingServiceMoreTest` — a past-period stamp reads as a fresh
grant, a current stamp reads the stored balance, an unstamped row reads
as a fresh grant, the grant follows the Stripe window rather than the
calendar month, plus the `remainingForPeriod` rule itself including a
future stamp and null/negative balances.
- `JobChargeServiceTest` — the first charge of a new period resets and
re-stamps, an unstamped row resets, a zero-grant policy still advances
the stamp, and a refund crossing a period boundary does not exceed the
grant.

Manually, against a team whose grant is spent: set
`free_units_period_start` back a month (or leave it NULL) and the
wallet, the sidebar meter and the entitlement gate should all show a
full grant before any job runs. The first billable job should then draw
from it and write the reset.

Three tests fail on a local Windows run and pass in CI, on files this
branch does not touch: `workbenchSession.test.ts`,
`notificationActions.test.tsx`, and `:proprietary`
`FolderIdentitiesTest.identityAgreesAcrossASymlinkedAliasOfTheDirectory`.
Nothing to do here — noted so a local run does not look like a
regression.

## Merge order

The migration is additive, and Hibernate `ddl-auto=update` will add the
column in a dev environment, so either order works locally. Beyond that
the schema goes first: Stirling-Tools/Stirling-PDF-SaaS#327 targets `v3`
(staging), so it needs to reach an environment before this lands there.
2026-09-02 13:57:56 +00:00
Anthony Stirling 798ba57f0b Reply to chat in the user's UI language (#7766)
# Description of Changes

Pass a user browser lang ID to engine

<img width="1400" height="900" alt="image"
src="https://github.com/user-attachments/assets/7e8fc5c2-8881-4a74-b718-7f5cd350d457"
/>



---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] Every comment I added says something the code does not
([guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/CODE_COMMENTS.md))
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-09-02 12:35:22 +00:00
Anthony Stirling 42bdce155c Fix mobile scanner upload flow and fit it to one screen (#7684)
file mobile phone scanner UI issues when on http and scaling UI issues
Ensuring that smaller screens dont cut off UI elements 
better handling of batch photos

<img width="2104" height="8800" alt="montage_mobile-scanner"
src="https://github.com/user-attachments/assets/b4dd114b-c54d-4101-8700-7307dbb0eee9"
/>


---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-09-02 09:13:31 +00:00
Reece e4cb26be43 refactor(files): Local in the tree sets the source filter
It was a pseudo-tab with a view of its own: its own predicate for which files
count, its own empty state, and a carve-out anywhere folders are involved -
folder visibility, the New folder button, the heading. All of it to say "files
with no server copy", which the source filter already says.

Clicking it now sets that filter and nothing else, so it narrows whichever view
you are in instead of taking you somewhere. The tab value goes with the
machinery, and the strings only its empty state read.
2026-09-01 22:54:13 +01:00
Reece 6fc1a39970 perf(files): render a window of a long folder, and drop the 500-file cap
A mounted directory listed at most 500 files. The cap was there because the grid
rendered a card for every entry, so a big Downloads folder was slow whether or not
the user scrolled that far - it traded away the rest of the folder to stay usable.

The grid and the list now render only the rows in view plus a spacer at each end,
so DOM size tracks the viewport instead of the folder. Spacers rather than
absolute positioning, so the grid keeps its own auto-fill layout and the list its
row flow; the column count is read off the computed style, leaving the CSS the one
place that decides it. With no measurable scrolling ancestor - a short list, the
first paint, a test environment with no geometry - every item renders, as before.

The cap goes with it: listDirectory returns what the directory holds.

What this does not change is the IPC cost of listing. Each entry is a stat over
the bridge, batched, so a 10k-file directory still pays for 10k stats before the
first card appears.
2026-09-01 19:05:41 +01:00
Reece 5be8c72172 Merge branch 'folder-kinds' into files-grid-perf
Both sides restructured FileGrid: folder kinds added disk-listed cards and
kind-aware folder menus, this branch made every item memoized behind one stable
actions dispatcher and took the folder context back out of the items.

The dispatcher stays, and the new behaviour moves onto it. Folder menus keep
their kind gating, deriving editsDisabled from the serverReachable prop rather
than subscribing to the folder context - a subscription inside a memoized item
undoes what the memo buys. Opening a disk-listed file becomes
actions.openDiskFile, so DiskFileCard and DiskFileRow take the dispatcher instead
of a closure rebuilt every render, and are memoized like every other item.
2026-09-01 17:51:43 +01:00
Reece da77a7c099 docs(folders): fix the eight clunky ones
Two deleted outright: an upload branch and a folder lookup whose comments said
what the condition below them said.

The rest kept their fact and lost the rest of the sentence. DiskFileCard gets
back the constraint that makes it unusual - no stub, so no selection or move.
The "Local" tab keeps only the both-halves rule, not the predicate beside it.
The disk-subfolder state says it is never persisted rather than restating its
type. FolderRecord.kind pointed at folderKind twice over; now the accessor holds
the rule and the field points at it.
2026-09-01 16:45:29 +01:00
Reece 67e4f4b301 docs(folders): delete the comments that say what the line below says
Nine that carried nothing: four sat above a throw whose message was the comment,
the rest restated the name or type they documented.

Two were wrong rather than redundant. One counted two systems of record where
three stores are loaded. The other explained local-file membership above the
branch that reports server files being left behind.
2026-09-01 16:30:01 +01:00
Reece 8afc769f05 test(files): count card re-renders so the memoization cannot rot
The restructure's whole claim is that selecting a file redraws the cards whose
selection changed rather than the folder. Nothing enforced it: one inline object
or closure at a call site undoes every bit of it, with no visible symptom until a
folder is large enough to feel it.

Counts the badge row each card renders exactly once, selects one of four, and
expects one card's worth of redraw. With React.memo stripped from FileCard the
same test reports four.
2026-09-01 16:14:55 +01:00
Reece 218a8400ca perf(files): big file lists render only what changed and only what shows
Three compounding costs made a full folder feel sticky:

- Every card and row re-rendered on ANY page state change, because item
  components weren't memoized and got fresh closures each render. Items
  now take a single stable actions dispatcher (latest-ref backed, so
  behavior stays current while identity stays fixed) and are React.memo —
  a selection click re-renders the two cards whose selection changed, not
  all 500. Selection-aware behavior (drag payloads, multi-move) moved
  into the dispatcher so items no longer hold the selection Set, whose
  identity changes on every click.
- Each lazily generated thumbnail updated the shared stub immediately,
  re-rendering every file-list consumer once per thumbnail — hundreds of
  times as a folder fills in. Updates now flush in windows; the card
  itself paints instantly from local state.
- Offscreen cards still paid layout and paint. content-visibility lets
  the browser skip them; the intrinsic size keeps the scrollbar honest.
2026-09-01 16:14:54 +01:00
Reece bfb48c88de docs(folders): put back the halves that carried the reason
Cutting each block to its first sentence sometimes kept the what and dropped the
why, which leaves a comment saying what the signature already says. Those are
deleted where the name covers them, and where the second sentence was the point
it is back: the OS error codes behind isAlreadyExists, why a virtual folder
cannot hang off a server one, the effect that snaps folder selection back to root.
2026-09-01 16:07:32 +01:00
Reece b6139d1cd0 docs(folders): one line each
Every multi-line aside cut to its first sentence. What went was the second and
third sentences qualifying it.
2026-09-01 16:00:26 +01:00
Reece 056de6d9ee docs(folders): cut the comments back
Same facts, a third of the words. Mostly three- and four-line asides saying one
thing, and rhetorical framing around explanations that stand up on their own.
2026-09-01 15:53:09 +01:00
Reece 90b6762869 docs(folders): trim the comments this branch adds
The same three lines explaining which folder kinds can go offline sat above both
the grid card and the list row; one copy carries the reasoning and the other
points at it.

The rest is the module docs on the new stores, saying the same things with less
around them.
2026-09-01 15:35:40 +01:00
Reece c7fc306605 style(folders): oxfmt after banner removal 2026-09-01 14:50:30 +01:00
Reece 539b933ce8 style(folders): drop two decorative section banners
main's comment gate blocks banner comments on added lines (CMT002):
decoration carries nothing a reader could not get from the code below it.
2026-09-01 14:35:34 +01:00
Reece Browne c0de945f32 Merge branch 'main' into folder-kinds 2026-09-01 14:29:32 +01:00
Reece da5edb2d3a feat(folders): folder kinds — server folders everywhere, disk mounts on desktop
Folders now carry a kind, and each kind has its own system of record:

- "server": the backend owns them, as before. The only kind the web
  offers — the root New-folder button goes straight to the server dialog
  and greys out with the reason (sign in / storage off / unreachable)
  when the server can't take one.
- "local": a directory on the machine, mounted read-through on desktop
  via the native picker ("Add local folder"). The directory is the source
  of truth: the listing is taken fresh from disk (stats batched — a
  directory's open time is IPC latency, so the calls overlap), opening a
  file loads its bytes into the workbench, and moving, dropping, or
  uploading files into the mount writes them to the directory itself —
  the app copy is retired only after the bytes verifiably land, taking
  superseded versions with it. Names are reduced to a safe basename
  before writing; collisions take the OS's " (n)" suffix convention.
  Mount records dedupe through a lexical directory key (case-folded for
  Windows-style paths, separators unified) and refuse nested or
  containing directories — one directory, one row.
- "virtual": browser-owned IndexedDB folders. Dormant by decision:
  nothing creates one at the root any more, but existing rows still
  render, take subfolders, and hold files.

One kind per subtree, always — each kind has its own store and a mixed
chain would mean an ancestry no single store can vouch for.

Placement is part of creation: a file uploaded while standing in a
folder is born with that folderId, set atomically with the stub — for a
server folder the save-to-server is the sync step, and a failed sync
leaves the file visibly in its folder rather than stranded. moveFilesTo
falls back to storage for ids newer than its render-time snapshot, so
just-born files never silently drop out of a move.

Platform gating goes through build seams (@app): the directory picker,
the disk listing/read/write, and the server-folder blocker — desktop's
blocker speaks in connection modes ("Sign in to Stirling Cloud or
connect a self-hosted server"), seeded from the service's cache so first
paint answers correctly. The one-click New-folder surfaces (sidebar
rail, empty-state CTA) share one flow: the native picker on desktop, a
server folder on the web, disabled with the reason when neither applies.
2026-09-01 13:09:21 +01:00
157 changed files with 7252 additions and 2365 deletions
+1
View File
@@ -312,3 +312,4 @@ docs/type3/signatures/
# Local screenshot artifacts from *-screenshots.spec.ts
frontend/editor/screenshots/
frontend/editor/src-tauri/libs/.variant
@@ -25,4 +25,7 @@ public class AiWorkflowRequest {
"Prior chat messages exchanged between the user and the assistant, ordered"
+ " oldest-first. Excludes the current userMessage.")
private List<AiConversationMessage> conversationHistory = new ArrayList<>();
@Schema(description = "IETF language tag the reply should be written in", example = "fr-FR")
private String locale;
}
@@ -398,6 +398,8 @@ public class PolicyController {
policy.name(),
owner,
policy.enabled(),
policy.required(),
policy.icon(),
policy.inputs(),
policy.steps(),
policy.output(),
@@ -21,6 +21,8 @@ public record Policy(
String name,
String owner,
boolean enabled,
boolean required,
String icon,
List<PipelineInput> inputs,
List<PipelineStep> steps,
OutputSpec output,
@@ -29,6 +31,7 @@ public record Policy(
EditorConfig editor) {
public Policy {
icon = icon == null ? "" : icon;
inputs = inputs == null ? List.of() : List.copyOf(inputs);
steps = steps == null ? List.of() : steps;
output = output == null ? OutputSpec.inline() : output;
@@ -36,7 +39,11 @@ public record Policy(
editor = editor == null ? EditorConfig.disabled() : editor;
}
/** Without editor participation: a swept or on-demand policy. */
/**
* Without the {@code required} flag, {@code icon}, or editor participation: defaults to not
* org-required, no icon, and a swept/on-demand policy. Kept for the many callers and tests
* written before those fields; the frontend and stores that care use the full constructor.
*/
public Policy(
String id,
String name,
@@ -47,7 +54,26 @@ public record Policy(
OutputSpec output,
List<String> outputIds,
Long teamId) {
this(id, name, owner, enabled, inputs, steps, output, outputIds, teamId, null);
this(id, name, owner, enabled, false, "", inputs, steps, output, outputIds, teamId, null);
}
/**
* Without the {@code required} flag or {@code icon} but with explicit editor participation: the
* seeded Classification policy runs on the editor, so it must set {@link EditorConfig} even
* though it predates the org-required and icon fields.
*/
public Policy(
String id,
String name,
String owner,
boolean enabled,
List<PipelineInput> inputs,
List<PipelineStep> steps,
OutputSpec output,
List<String> outputIds,
Long teamId,
EditorConfig editor) {
this(id, name, owner, enabled, false, "", inputs, steps, output, outputIds, teamId, editor);
}
/**
@@ -108,19 +134,32 @@ public record Policy(
/** A copy with the inline output replaced (e.g. resolved for the engine, or migrated). */
public Policy withOutput(OutputSpec resolved) {
return new Policy(
id, name, owner, enabled, inputs, steps, resolved, outputIds, teamId, editor);
id, name, owner, enabled, required, icon, inputs, steps, resolved, outputIds,
teamId, editor);
}
/** A copy under a different owner (e.g. moving a seed off a placeholder name). */
public Policy withOwner(String newOwner) {
return new Policy(
id, name, newOwner, enabled, inputs, steps, output, outputIds, teamId, editor);
id, name, newOwner, enabled, required, icon, inputs, steps, output, outputIds,
teamId, editor);
}
/** A copy referencing the given saved output destinations. */
public Policy withOutputIds(List<String> newOutputIds) {
return new Policy(
id, name, owner, enabled, inputs, steps, output, newOutputIds, teamId, editor);
id,
name,
owner,
enabled,
required,
icon,
inputs,
steps,
output,
newOutputIds,
teamId,
editor);
}
/**
@@ -20,28 +20,23 @@ import stirling.software.proprietary.policy.source.SourceStore;
import stirling.software.proprietary.policy.store.PolicyStore;
/**
* Builds the Pipelines overview: one row per policy the caller's team built on the Pipelines page,
* with its sources resolved to live display names, its steps, and a trigger/output summary.
* Frontend/catalogue policies (marked by a {@code categoryId} in their output options) belong to
* the user-facing Policies page and are excluded; a folder-watch trigger is not a signal.
* Builds the unified Pipelines overview: one row per policy the caller's team owns, with its
* sources resolved to live display names, its steps, and a trigger/output summary. This lists EVERY
* policy - both pipelines built in the full builder and the friendly "suggested" policies - since
* the two surfaces were merged (a policy is a pipeline the org requires). No catalogue filter any
* more.
*/
@Service
@RequiredArgsConstructor
public class PolicyOverviewService {
// Output-options key marking a frontend/catalogue policy (set by the Policies page and seeder).
private static final String CATEGORY_OPTION = "categoryId";
private final PolicyStore policyStore;
private final SourceStore sourceStore;
private final PolicyAccessGuard policyAccessGuard;
private final SourceAccessGuard sourceAccessGuard;
public PoliciesOverviewResponse overview() {
List<Policy> policies =
policyAccessGuard.visibleFrom(policyStore).stream()
.filter(PolicyOverviewService::isPipeline)
.toList();
List<Policy> policies = policyAccessGuard.visibleFrom(policyStore).stream().toList();
Map<String, String> sourceNames = sourceNames();
List<PolicyView> views =
@@ -55,18 +50,6 @@ public class PolicyOverviewService {
return new PoliciesOverviewResponse(buildKpis(policies), views);
}
private static boolean isPipeline(Policy policy) {
return !isCataloguePolicy(policy);
}
/** A frontend/catalogue policy, marked by a {@code categoryId} in its output options. */
private static boolean isCataloguePolicy(Policy policy) {
OutputSpec output = policy.output();
return output != null
&& output.options().get(CATEGORY_OPTION) instanceof String category
&& !category.isBlank();
}
/** Display names for every source the caller's team can see, keyed by source id. */
private Map<String, String> sourceNames() {
Map<String, String> names = new HashMap<>();
@@ -88,6 +71,8 @@ public class PolicyOverviewService {
policy.id(),
policy.name(),
policy.enabled(),
policy.required(),
iconKey(policy),
policy.enabled() ? "active" : "paused",
triggerSummary(policy),
sources,
@@ -111,6 +96,25 @@ public class PolicyOverviewService {
return outputSummary(policy.output());
}
/**
* The list-row icon key. The policy's first-class {@code icon} wins; otherwise a
* template-derived policy falls back to its {@code categoryId} (the template-identity marker
* the frontend maps to the category glyph). Empty when neither is set, so the frontend shows
* its default.
*/
private static String iconKey(Policy policy) {
if (!policy.icon().isBlank()) {
return policy.icon();
}
OutputSpec output = policy.output();
if (output != null
&& output.options().get("categoryId") instanceof String category
&& !category.isBlank()) {
return category;
}
return "";
}
/**
* Summarise a policy's triggers for the overview row: "manual" when no input is triggered,
* otherwise the distinct trigger types across its inputs (e.g. "folder-watch, schedule").
@@ -3,15 +3,18 @@ package stirling.software.proprietary.policy.overview;
import java.util.List;
/**
* One row in the Pipelines overview: a stored policy shown for the admin portal, with its
* referenced sources resolved to names and its pipeline summarised. The portal's "all pipelines"
* surface lists every backend policy (the user-facing Policies page builds only a friendly subset
* of these).
* One row in the unified Pipelines overview: a stored policy shown for the admin portal, with its
* referenced sources resolved to names and its pipeline summarised. This surface lists every
* backend policy - both the pipelines built in the full builder and the friendly "suggested"
* policies - so a {@code required} policy (one the org mandates) reads the same as any other
* pipeline here.
*/
public record PolicyView(
String id,
String name,
boolean enabled,
boolean required,
String icon,
String status,
String trigger,
List<SourceRef> sources,
@@ -34,6 +34,8 @@ public class InProcessPolicyStore implements PolicyStore {
policy.name(),
policy.owner(),
policy.enabled(),
policy.required(),
policy.icon(),
policy.inputs(),
policy.steps(),
policy.output(),
@@ -17,6 +17,7 @@ import stirling.software.proprietary.policy.model.Policy;
import stirling.software.proprietary.policy.model.PolicyBinding;
import stirling.software.proprietary.policy.source.EditorSource;
import tools.jackson.databind.DeserializationFeature;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.ObjectMapper;
import tools.jackson.databind.node.ArrayNode;
@@ -47,6 +48,8 @@ public class JpaPolicyStore implements PolicyStore {
policy.name(),
policy.owner(),
policy.enabled(),
policy.required(),
policy.icon(),
policy.inputs(),
policy.steps(),
policy.output(),
@@ -155,7 +158,14 @@ public class JpaPolicyStore implements PolicyStore {
JsonNode node =
liftEditorConfig(
upgradeLegacyShape(objectMapper.readTree(entity.getPolicyJson())));
return Optional.of(objectMapper.treeToValue(node, Policy.class));
// A blob written by an older version won't carry fields added since (e.g. required,
// icon). Default absent primitives rather than rejecting the whole policy, so upgrades
// don't drop existing pipelines.
return Optional.of(
objectMapper
.readerFor(Policy.class)
.without(DeserializationFeature.FAIL_ON_NULL_FOR_PRIMITIVES)
.readValue(node));
} catch (Exception e) {
log.error(
"Skipping unreadable policy id={} name={}: stored JSON could not be parsed"
@@ -185,6 +185,7 @@ public class AiWorkflowService {
initialRequest.setConversationHistory(
new ArrayList<>(request.getConversationHistory()));
initialRequest.setEnabledEndpoints(endpointResolver.getEnabledEndpointUrls());
initialRequest.setLocale(request.getLocale());
listener.onProgress(AiWorkflowProgressEvent.of(AiWorkflowPhase.ANALYZING));
WorkflowState state = new WorkflowState.Pending(initialRequest);
@@ -287,6 +288,7 @@ public class AiWorkflowService {
nextRequest.setArtifacts(pdfContentExtractor.buildArtifacts(contentResults));
nextRequest.setResumeWith(response.getResumeWith());
nextRequest.setEnabledEndpoints(request.getEnabledEndpoints());
nextRequest.setLocale(request.getLocale());
return new WorkflowState.Pending(nextRequest);
} finally {
for (LoadedFile lf : loadedFiles) {
@@ -338,6 +340,7 @@ public class AiWorkflowService {
nextRequest.setFiles(request.getFiles());
nextRequest.setConversationHistory(request.getConversationHistory());
nextRequest.setResumeWith(response.getResumeWith());
nextRequest.setLocale(request.getLocale());
return new WorkflowState.Pending(nextRequest);
}
@@ -530,6 +533,7 @@ public class AiWorkflowService {
new PdfContentExtractor.ToolReportArtifact(
result.reportTool(), result.report()));
resumeRequest.setResumeWith(resumeWith);
resumeRequest.setLocale(previousRequest.getLocale());
return new WorkflowState.Pending(resumeRequest);
}
@@ -802,5 +806,6 @@ public class AiWorkflowService {
private List<WorkflowArtifact> artifacts = new ArrayList<>();
private String resumeWith;
private List<String> enabledEndpoints = new ArrayList<>();
private String locale;
}
}
@@ -29,11 +29,11 @@ import stirling.software.proprietary.policy.store.InProcessPolicyStore;
import stirling.software.proprietary.policy.store.PolicyStore;
/**
* Tests for {@link PolicyOverviewService}: every Pipelines-page policy appears once with its
* sources resolved to names, its steps and trigger/output summarised, and the KPI strip counting
* active vs paused. Frontend/catalogue policies (owned by the Policies page) are excluded, while a
* pipeline that uses a folder-watch trigger stays. Login is disabled so the team guards pass
* everything through.
* Tests for {@link PolicyOverviewService}: every policy the caller's team owns appears once with
* its sources resolved to names, its steps and trigger/output summarised, and the KPI strip
* counting active vs paused. Since Policies were merged into Pipelines, the suggested ("catalogue")
* policies are listed alongside hand-built pipelines - nothing is filtered. Login is disabled so
* the team guards pass everything through.
*/
class PolicyOverviewServiceTest {
@@ -99,9 +99,9 @@ class PolicyOverviewServiceTest {
}
@Test
void excludesCataloguePoliciesButKeepsFolderWatchPipelines() {
void listsEveryPolicyIncludingSuggestedOnes() {
Source inbox = source("Inbox", "/inbox");
// A hand-built pipeline: shows.
// A hand-built pipeline.
policyStore.save(
new Policy(
null,
@@ -111,7 +111,7 @@ class PolicyOverviewServiceTest {
List.of(),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline()));
// A folder-watch pipeline is still a pipeline: shows.
// A folder-watch pipeline.
policyStore.save(
new Policy(
null,
@@ -123,7 +123,7 @@ class PolicyOverviewServiceTest {
inbox.id(), new TriggerConfig("folder-watch", Map.of()))),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline()));
// A frontend/catalogue policy (categoryId in output options): hidden.
// A suggested ("catalogue") policy (categoryId in output options): now listed too.
policyStore.save(
new Policy(
null,
@@ -137,10 +137,68 @@ class PolicyOverviewServiceTest {
PoliciesOverviewResponse response = service.overview();
assertEquals(
List.of("Compress pipeline", "Inbox watcher"),
List.of("Classification Policy", "Compress pipeline", "Inbox watcher"),
response.pipelines().stream().map(PolicyView::name).toList());
// KPIs count both visible pipelines, not the hidden catalogue policy.
assertEquals(List.of(2L, 2L, 0L), response.kpis().stream().map(PolicyKpi::value).toList());
// KPIs count all three.
assertEquals(List.of(3L, 3L, 0L), response.kpis().stream().map(PolicyKpi::value).toList());
}
@Test
void requiredFlagSurfacesInTheView() {
policyStore.save(
new Policy(
null,
"Mandatory redaction",
"owner",
true,
true,
"",
List.of(),
List.of(new PipelineStep("/api/v1/security/auto-redact", Map.of())),
OutputSpec.inline(),
List.of(),
null,
EditorConfig.disabled()));
PolicyView view = find(service.overview(), "Mandatory redaction");
assertTrue(view.required());
}
@Test
void iconIsExplicitOtherwiseFallsBackToCategory() {
// The policy's first-class icon wins.
policyStore.save(
new Policy(
null,
"Custom with icon",
"owner",
true,
false,
"shield",
List.of(),
List.of(new PipelineStep("/api/v1/misc/compress-pdf", Map.of())),
OutputSpec.inline(),
List.of(),
null,
EditorConfig.disabled()));
// No explicit icon: a template-derived policy falls back to its categoryId marker.
policyStore.save(
new Policy(
null,
"Template derived",
"owner",
true,
false,
"",
List.of(),
List.of(new PipelineStep("/api/v1/security/auto-redact", Map.of())),
new OutputSpec("inline", Map.of("categoryId", "security")),
List.of(),
null,
EditorConfig.disabled()));
assertEquals("shield", find(service.overview(), "Custom with icon").icon());
assertEquals("security", find(service.overview(), "Template derived").icon());
}
@Test
@@ -132,10 +132,7 @@ public class PaygWalletController {
Objects.requireNonNull(prepaidBundleService, "prepaidBundleService");
}
// ---------------------------------------------------------------------------------------
// GET /wallet — the single FE fetch
// ---------------------------------------------------------------------------------------
/** The single wallet fetch the frontend makes; every figure on the Plan page comes from it. */
@GetMapping("/wallet")
@PreAuthorize("isAuthenticated()")
@Transactional(readOnly = true)
@@ -175,9 +172,8 @@ public class PaygWalletController {
: null;
// Per-state by construction (see EntitlementService.computeSnapshot): free team → spend is
// lifetime free used, cap is the grant size; subscribed → spend is this month's net
// billable
// docs, cap is the monthly paid-doc ceiling (null = uncapped).
// this period's free used, cap is the period grant size; subscribed → spend is this
// period's net billable docs, cap is the monthly paid-doc ceiling (null = uncapped).
int spend = clampToInt(snap.periodSpendUnits());
Integer limit = snap.periodCapUnits() != null ? clampToInt(snap.periodCapUnits()) : null;
@@ -328,10 +324,7 @@ public class PaygWalletController {
};
}
// ---------------------------------------------------------------------------------------
// PATCH /cap — leader-only, cap is application-layer, no Stripe call
// ---------------------------------------------------------------------------------------
/** Leader-only. The cap is enforced in the application layer; Stripe is never called. */
@PatchMapping("/cap")
@PreAuthorize("isAuthenticated()")
@Transactional
@@ -395,10 +388,6 @@ public class PaygWalletController {
/** Request body for {@link #updateCap}. */
public record UpdateCapRequest(@Min(0) int capUsd, boolean noCap) {}
// ---------------------------------------------------------------------------------------
// POST /wallet/refresh — drop the caller's cached snapshot so the next read is fresh
// ---------------------------------------------------------------------------------------
/**
* Drops the caller's team snapshot + billing cache so the next {@code GET /wallet} reflects a
* billing state that just changed out-of-band. The subscription flip is written by a Postgres
@@ -421,10 +410,6 @@ public class PaygWalletController {
return ResponseEntity.noContent().build();
}
// ---------------------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------------------
private Optional<TeamMembership> primaryMembership(Long userId) {
List<TeamMembership> rows = memberRepo.findPrimaryMembership(userId);
return rows.isEmpty() ? Optional.empty() : Optional.of(rows.getFirst());
@@ -9,7 +9,7 @@ import java.util.List;
* breakdowns, recent activity) used by the PAYG Plan page.
*
* <p>Every number is real: the billing window is the Stripe subscription's current period (via Sync
* Engine) for subscribed teams, the one-time free grant size comes from {@code
* Engine) for subscribed teams, the per-period free grant size comes from {@code
* pricing_policy.free_tier_units} (live balance from {@code
* payg_team_extensions.free_units_remaining}), and the per-document rate comes from the
* subscription's Stripe Price. Fields that can't be resolved are {@code null} and the FE renders
@@ -26,15 +26,15 @@ import java.util.List;
* subscription period when subscribed, the calendar month otherwise.
* @param billingPeriodEnd exclusive ISO date (yyyy-MM-dd) for the current cycle.
* @param billableUsed alias of {@code spendUnitsThisPeriod} kept for clarity in the FE. For a free
* team this is the lifetime free documents used so far ({@code freeAllowance freeRemaining});
* for a subscribed team it's this month's net billable documents.
* @param billableLimit the team's document ceiling for the matching window: the one-time free grant
* ({@code freeAllowance}) for free teams; {@code floor(cap / perDocRate)} paid docs/month for
* capped subscribed teams; {@code null} when subscribed with no cap (uncapped).
* @param freeAllowance the team's one-time free document grant size (the "N" in "X of N free").
* Never resets; survives subscribing. Applies to billable categories only.
* @param freeRemaining one-time free documents still available to the team ({@code
* payg_team_extensions.free_units_remaining}). 0 = grant exhausted.
* team this is the free documents used so far this period ({@code freeAllowance
* freeRemaining}); for a subscribed team it's this period's net billable documents.
* @param billableLimit the team's document ceiling for the matching window: this period's free
* grant ({@code freeAllowance}) for free teams; {@code floor(cap / perDocRate)} paid docs/month
* for capped subscribed teams; {@code null} when subscribed with no cap (uncapped).
* @param freeAllowance the team's free document grant size per period (the "N" in "X of N free").
* Resets each period. Applies to billable categories only.
* @param freeRemaining free documents still available to the team this period ({@code
* payg_team_extensions.free_units_remaining}). 0 = this period's grant is exhausted.
* @param pricePerDocMinor paid per-document rate in minor units of {@code currency} (may be
* fractional — Stripe supports sub-cent rates); {@code null} when the rate can't be resolved.
* @param currency lower-case ISO 4217 currency of the subscription's Stripe Price; {@code null}
@@ -4,28 +4,20 @@ import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* One team's billing facts, composed by {@link TeamBillingService}. Two independent meters live
* here and must not be conflated:
*
* <ul>
* <li>the <b>one-time lifetime free grant</b> ({@link #freeGrantUnits} total, {@link
* #freeRemainingUnits} left) — gates an un-subscribed team and decides the free-vs-paid split
* of every job; never resets, survives subscribing;
* <li>the <b>monthly billing window</b> ({@link #periodStart}/{@link #periodEnd}) and the
* optional monthly spending cap ({@link #monthlyCapDocUnits}) — govern the subscribed invoice
* + cap only.
* </ul>
* One team's billing facts, composed by {@link TeamBillingService}. The free grant and the spending
* cap are separate pools measured over one window.
*
* @param subscribed team has a live PAYG subscription — i.e. {@code payg_subscription_id} is set.
* Cleared by {@code payg_unlink_subscription} on cancellation, so a cancelled team reads false.
* @param subscriptionId {@code payg_team_extensions.payg_subscription_id}; null when free
* @param periodStart inclusive start of the monthly billing window — the Stripe subscription's
* current period when subscribed, calendar month otherwise
* @param periodEnd exclusive end of the monthly billing window
* @param freeGrantUnits the team's one-time free grant size (policy {@code free_tier_units}); the
* denominator for "used X of N free". Never resets.
* @param freeRemainingUnits one-time free documents still available ({@code
* payg_team_extensions.free_units_remaining}). 0 = grant exhausted.
* @param periodStart inclusive start of the billing window — the Stripe subscription's current
* period when subscribed, calendar month otherwise. Also the period the free grant resets on.
* @param periodEnd exclusive end of the billing window
* @param freeGrantUnits the team's free grant size per period (policy {@code free_tier_units}); the
* denominator for "used X of N free"
* @param freeRemainingUnits free documents still available in this period ({@code
* payg_team_extensions.free_units_remaining}, via {@code
* TeamBillingService.remainingForPeriod}). 0 = exhausted.
* @param perDocMinor paid per-document rate in minor units of {@link #currency()}; null when the
* rate can't be resolved (free team, price row unsynced) — display "unknown", never substitute
* @param currency lower-case ISO 4217 of the subscription's Price; null when unknown
@@ -30,17 +30,8 @@ import stirling.software.saas.payg.wallet.WalletPolicy;
* entitlement hot path and the wallet endpoint read from here, so what the customer sees is what
* the guard enforces.
*
* <p>Two independent meters (design 2026-06-11 — the free allowance is a one-time lifetime grant):
*
* <ul>
* <li><b>Free grant</b> — one-time, per team. Size from {@code pricing_policy.free_tier_units};
* live balance from the {@code payg_team_extensions.free_units_remaining} counter (maintained
* by the charge pipeline). Never resets, survives subscribing. Gates un-subscribed teams and
* drives the free-vs-paid split.
* <li><b>Monthly window + cap</b> — the Stripe subscription period (calendar month otherwise) and
* the optional money cap. Govern the subscribed invoice + spending cap only. The per-document
* rate is the synced {@code stripe.prices.unit_amount} (PAYG prices are plain per-unit).
* </ul>
* <p>The free grant and the spending cap are separate pools measured over one window: the Stripe
* subscription period when subscribed, the calendar month otherwise.
*
* <p>Cached per team for {@value #CACHE_TTL_SECONDS}s. {@code EntitlementService.invalidate}
* cascades into {@link #invalidate(Long)} so both caches drop together on cap edits / webhooks.
@@ -133,12 +124,6 @@ public class TeamBillingService {
// bug this guards against.
boolean subscribed = subscriptionId != null;
long freeGrant = resolveGrant(teamId);
long freeRemaining =
extOpt.map(PaygTeamExtensions::getFreeUnitsRemaining)
.map(Long::longValue)
.orElse(0L);
Optional<SubscriptionBilling> billing =
subscriptionId != null
? subscriptionDao.findBilling(subscriptionId)
@@ -148,6 +133,19 @@ public class TeamBillingService {
billing.map(b -> new LocalDateTime[] {b.periodStart(), b.periodEnd()})
.orElseGet(TeamBillingService::calendarMonthWindow);
long freeGrant = resolveGrant(teamId);
// The reset is persisted lazily by the charge pipeline, so the raw counter still reads as
// last period's for a team that has run nothing since the boundary.
long freeRemaining =
extOpt.map(
ext ->
remainingForPeriod(
ext.getFreeUnitsPeriodStart(),
ext.getFreeUnitsRemaining(),
freeGrant,
window[0]))
.orElse(0L);
BigDecimal perDocMinor = billing.map(SubscriptionBilling::perDocMinor).orElse(null);
String currency = billing.map(SubscriptionBilling::currency).orElse(null);
@@ -184,7 +182,6 @@ public class TeamBillingService {
monthlyCapDocUnits);
}
/** The policy grant size — the "N" denominator for display; the counter is the live balance. */
private long resolveGrant(Long teamId) {
try {
PricingPolicy policy = pricingPolicyService.getEffectivePolicy(teamId);
@@ -198,8 +195,8 @@ public class TeamBillingService {
/**
* The subscribed monthly paid-document ceiling; {@code null} = uncapped or not subscribed. The
* one-time free grant is NOT added here — it's a separate lifetime pool consumed at charge
* time. The cap purely limits how many paid documents the team will fund per billing period.
* free grant is NOT added here — it's a separate per-period pool consumed at charge time, ahead
* of the meter. The cap purely limits how many paid documents the team will fund per period.
*
* <ul>
* <li>not subscribed → null (the free grant, not a money cap, is what bounds them);
@@ -250,7 +247,7 @@ public class TeamBillingService {
/**
* Documents a hypothetical monthly money cap would buy: {@code floor(capMinor / rate)}. Used by
* the cap editor's live preview and the {@code PATCH /cap} derived write. The free grant is NOT
* added — it's a separate one-time pool. Empty when the rate is unknown.
* added — it's a separate per-period pool. Empty when the rate is unknown.
*/
public Optional<Long> docCapForMoney(TeamBillingContext ctx, long capMinor) {
if (ctx.perDocMinor() == null || ctx.perDocMinor().signum() <= 0) {
@@ -279,6 +276,28 @@ public class TeamBillingService {
.orElse(null);
}
/**
* The team's free balance for the period starting at {@code currentPeriodStart}: a full grant
* when the counter is stale, the counter otherwise. Shared with the decrement in {@code
* JobChargeService} so displayed and enforced balances cannot diverge.
*/
public static long remainingForPeriod(
LocalDateTime stampedPeriodStart,
Long storedRemaining,
long grant,
LocalDateTime currentPeriodStart) {
if (isStale(stampedPeriodStart, currentPeriodStart)) {
return Math.max(0L, grant);
}
return storedRemaining == null ? 0L : Math.max(0L, storedRemaining);
}
public static boolean isStale(
LocalDateTime stampedPeriodStart, LocalDateTime currentPeriodStart) {
return currentPeriodStart != null
&& (stampedPeriodStart == null || stampedPeriodStart.isBefore(currentPeriodStart));
}
/**
* Inclusive-start / exclusive-end window for the calendar month — the monthly billing window
* used when there's no Stripe subscription period to anchor on.
@@ -17,6 +17,8 @@ import org.springframework.web.multipart.MultipartFile;
import lombok.extern.slf4j.Slf4j;
import stirling.software.saas.payg.billing.TeamBillingContext;
import stirling.software.saas.payg.billing.TeamBillingService;
import stirling.software.saas.payg.bundle.PrepaidBundleService;
import stirling.software.saas.payg.docs.DocumentClassifier;
import stirling.software.saas.payg.docs.DocumentMetrics;
@@ -70,6 +72,7 @@ public class JobChargeService {
private final PaygMeterReportingService meterReportingService;
private final WalletLedgerRepository ledgerRepository;
private final PrepaidBundleService prepaidBundleService;
private final TeamBillingService teamBillingService;
public JobChargeService(
JobService jobService,
@@ -80,7 +83,8 @@ public class JobChargeService {
PaygTeamExtensionsRepository teamExtensionsRepository,
PaygMeterReportingService meterReportingService,
WalletLedgerRepository ledgerRepository,
PrepaidBundleService prepaidBundleService) {
PrepaidBundleService prepaidBundleService,
TeamBillingService teamBillingService) {
this.jobService = Objects.requireNonNull(jobService, "jobService");
this.policyService = Objects.requireNonNull(policyService, "policyService");
this.classifier = Objects.requireNonNull(classifier, "classifier");
@@ -93,6 +97,7 @@ public class JobChargeService {
this.ledgerRepository = Objects.requireNonNull(ledgerRepository, "ledgerRepository");
this.prepaidBundleService =
Objects.requireNonNull(prepaidBundleService, "prepaidBundleService");
this.teamBillingService = Objects.requireNonNull(teamBillingService, "teamBillingService");
}
/**
@@ -208,13 +213,13 @@ public class JobChargeService {
}
/**
* Draw this job's free portion from the team's one-time lifetime grant, atomically, and return
* the units taken (0..{@code units}); the remainder is the paid portion that will be metered to
* Stripe. Runs inside {@code openProcess}'s transaction with a pessimistic row lock so
* concurrent same-team charges split the grant exactly — no two jobs can both claim the last
* free unit. The grant is a soft floor: it never goes below 0, and the single job that crosses
* the boundary takes whatever's left (its remaining units bill). Skipped for non-billable /
* team-less calls (BYPASSED never reaches openProcess; guarded defensively).
* Units of {@code units} drawn from the team's grant for the current period; the remainder is
* metered to Stripe. The grant is a soft floor, so the job crossing the boundary takes what is
* left and bills the rest.
*
* <p>Also the only writer of the period reset. Both happen under {@code openProcess}'s row
* lock, against the balance on the locked row rather than the cached context, so concurrent
* same-team charges cannot both claim the last free unit.
*/
private int consumeFreeGrant(ChargeContext ctx, int units) {
BillingCategory category = ctx.billingCategory();
@@ -227,15 +232,51 @@ public class JobChargeService {
return 0;
}
PaygTeamExtensions ext = extOpt.get();
long remaining = ext.getFreeUnitsRemaining() == null ? 0L : ext.getFreeUnitsRemaining();
TeamBillingContext billing = teamBillingService.forTeam(ctx.ownerTeamId());
LocalDateTime periodStart = billing.periodStart();
boolean periodRolled =
TeamBillingService.isStale(ext.getFreeUnitsPeriodStart(), periodStart);
long remaining =
TeamBillingService.remainingForPeriod(
ext.getFreeUnitsPeriodStart(),
ext.getFreeUnitsRemaining(),
billing.freeGrantUnits(),
periodStart);
int freeUsed = (int) Math.min(units, Math.max(0L, remaining));
if (freeUsed > 0) {
if (periodRolled || freeUsed > 0) {
// A roll-over writes even when nothing is drawn, so the stamp stops reading as stale.
ext.setFreeUnitsRemaining(remaining - freeUsed);
ext.setFreeUnitsPeriodStart(periodStart);
teamExtensionsRepository.save(ext);
}
return freeUsed;
}
/**
* Return {@code units} to the team's free grant, capped at one period's grant. The cap only
* bites when a refund lands after its charge's period ended, where the balance has already
* reset and adding the old units would over-credit the team. Locks rather than incrementing
* blindly because the cap applies against the balance as it stands.
*/
private void restoreFreeGrant(Long teamId, int units) {
Optional<PaygTeamExtensions> extOpt = teamExtensionsRepository.findByIdForUpdate(teamId);
if (extOpt.isEmpty()) {
return;
}
PaygTeamExtensions ext = extOpt.get();
TeamBillingContext billing = teamBillingService.forTeam(teamId);
long grant = billing.freeGrantUnits();
long remaining =
TeamBillingService.remainingForPeriod(
ext.getFreeUnitsPeriodStart(),
ext.getFreeUnitsRemaining(),
grant,
billing.periodStart());
ext.setFreeUnitsRemaining(Math.min(grant, remaining + Math.max(0, units)));
ext.setFreeUnitsPeriodStart(billing.periodStart());
teamExtensionsRepository.save(ext);
}
/**
* Draw this job's prepaid portion from the team's bundles — the tier after the free grant and
* before the meter — returning the units taken (0..{@code units}). Same guard as {@link
@@ -405,13 +446,11 @@ public class JobChargeService {
refund.setPolicyId(row.getPolicyId());
refund.setBillingCategory(category);
ledgerRepository.save(refund);
// Hand back the free units this job consumed (first-step failures are
// pre-meter, so nothing was billed to Stripe — only the grant moved). Exactly
// what was taken at charge time, so the counter can't drift above the grant.
// First-step failures are pre-meter: nothing was billed, only the grant moved.
int freeConsumed =
row.getFreeUnitsConsumed() == null ? 0 : row.getFreeUnitsConsumed();
if (freeConsumed > 0 && row.getTeamId() != null) {
teamExtensionsRepository.restoreFreeUnits(row.getTeamId(), freeConsumed);
restoreFreeGrant(row.getTeamId(), freeConsumed);
}
// Return the prepaid units this job drew to the team's pools (best-effort — see
// PrepaidBundleService.restore).
@@ -553,9 +592,7 @@ public class JobChargeService {
return;
}
// Paid portion = units beyond the team's one-time free grant, fixed at charge time. The
// free grant is app-side only (Stripe's Prices are plain per-unit, no free tier), so the
// free units were already withheld when this row's free_units_consumed was set.
// Free units are withheld app-side at charge time; Stripe's Prices carry no free tier.
int freeConsumed = row.getFreeUnitsConsumed() == null ? 0 : row.getFreeUnitsConsumed();
int bundleConsumed =
row.getBundleUnitsConsumed() == null ? 0 : row.getBundleUnitsConsumed();
@@ -134,8 +134,8 @@ public class EntitlementService {
if (billing.subscribed()) {
// Subscribed: gate on the monthly spending cap. Spend = this period's net billable
// documents (DEBIT minus REFUND so a refunded job doesn't read as spent). The one-time
// free grant doesn't gate a paying team — it only reduced what they were metered.
// documents (DEBIT minus REFUND so a refunded job doesn't read as spent). The free
// grant doesn't gate a paying team — it only reduced what they were metered.
long signedNet = ledgerRepository.sumPeriodNetBillable(teamId, periodStart, periodEnd);
long periodSpend = signedNet < 0 ? -signedNet : 0L;
Long cap = billing.monthlyCapDocUnits();
@@ -157,14 +157,8 @@ public class EntitlementService {
snapshotSpend = periodSpend;
snapshotCap = cap;
} else {
// Unsubscribed: gate on the one-time lifetime free grant, then on a prepaid pool. While
// the free grant has balance, evaluate the warn/degrade band on used-of-grant. Once the
// free grant is spent, a live prepaid pool keeps the team fully entitled — paid-for
// capacity is usable on its own merit, independent of any metered subscription (the
// pool
// is drawn in JobChargeService; only the metered remainder stays gated on the sub).
// Only
// when BOTH the free grant and prepaid are exhausted do billable categories hard-stop.
// A prepaid pool outranks an exhausted grant: paid-for capacity is usable on its own
// merit, with no subscription. Only with both gone do billable categories hard-stop.
long grant = billing.freeGrantUnits();
long remaining = billing.freeRemainingUnits();
long used = Math.max(0L, grant - remaining);
@@ -72,15 +72,22 @@ public class PaygTeamExtensions implements Serializable {
private String paygSubscriptionId;
/**
* Remaining one-time free documents for this team (the lifetime grant). Seeded from the
* effective pricing policy's {@code free_tier_units} when this row is created (V14 trigger,
* updated in V19); decremented by the charge pipeline when a billable charge is written and
* restored on a first-step refund. Never replenishes; survives subscribing. This counter — not
* the wallet ledger — is the source of truth for the grant, so old ledger rows can be pruned.
* Free documents left in the team's current billing period, reset to the policy's {@code
* free_tier_units} at each boundary (see {@link #freeUnitsPeriodStart}). This counter, not the
* wallet ledger, is the source of truth for the grant.
*/
@Column(name = "free_units_remaining", nullable = false)
private Long freeUnitsRemaining = 0L;
/**
* The billing period {@link #freeUnitsRemaining} was last reset for, always a {@code
* TeamBillingContext.periodStart}. {@code null} or older than the current period start means
* the counter is stale and reads as a full grant. Written only by the app, which owns the
* period rule.
*/
@Column(name = "free_units_period_start")
private LocalDateTime freeUnitsPeriodStart;
@CreationTimestamp
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@@ -74,11 +74,9 @@ public class PricingPolicy implements Serializable {
private Integer fileUnitCap = 1000;
/**
* One-time lifetime free document grant handed to a team on creation. {@code 0} (default) means
* no free grant. NOT per-cycle: it never replenishes and a team keeps any unused portion after
* subscribing. The value is copied into {@code payg_team_extensions.free_units_remaining} when
* the team's sidecar row is created (V14 trigger, updated in V19); from then on the per-team
* counter is authoritative and this column is only the seed for new teams.
* Free document grant a team gets each billing period; {@code 0} (default) means none. The
* size, not the balance: {@code payg_team_extensions.free_units_remaining} is reset to it at
* each period boundary and does not carry over.
*/
@Column(name = "free_tier_units", nullable = false)
private Long freeTierUnits = 0L;
@@ -4,7 +4,6 @@ import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
@@ -19,24 +18,10 @@ public interface PaygTeamExtensionsRepository extends JpaRepository<PaygTeamExte
Optional<PaygTeamExtensions> findByStripeCustomerId(String stripeCustomerId);
/**
* Pessimistic-write load of the sidecar row, used by the charge pipeline to deduct the one-time
* free grant atomically. The lock serialises concurrent charges <em>for the same team</em> so
* the per-job {@code free_units_consumed} split (and therefore the metered paid portion) is
* exact — two simultaneous jobs can't both believe they drew from the same remaining unit.
* Different teams never contend; the lock is held only for the {@code openProcess} transaction.
* Serialises concurrent charges for one team so the per-job {@code free_units_consumed} split
* is exact: without the lock two simultaneous jobs both draw the same remaining free unit.
*/
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT e FROM PaygTeamExtensions e WHERE e.teamId = :teamId")
Optional<PaygTeamExtensions> findByIdForUpdate(@Param("teamId") Long teamId);
/**
* Atomically returns {@code freeUnitsConsumed} to the team's grant on a refund. Increment is
* commutative so no lock is needed; the amount restored is exactly what the job consumed, so it
* can never exceed the original grant.
*/
@Modifying
@Query(
"UPDATE PaygTeamExtensions e SET e.freeUnitsRemaining = e.freeUnitsRemaining + :units"
+ " WHERE e.teamId = :teamId")
int restoreFreeUnits(@Param("teamId") Long teamId, @Param("units") long units);
}
@@ -59,10 +59,10 @@ public class PaygShadowCharge implements Serializable {
private Integer paygUnits;
/**
* How many of {@link #paygUnits} were drawn from the team's one-time free grant at charge time.
* The paid (Stripe-metered) portion is {@code paygUnits - freeUnitsConsumed}; a refund restores
* this many units to {@code payg_team_extensions.free_units_remaining}. {@code 0} for pre-V19
* rows and for jobs that consumed no free units (team's grant already exhausted).
* How many of {@link #paygUnits} were drawn from the team's free grant at charge time. The paid
* (Stripe-metered) portion is {@code paygUnits - freeUnitsConsumed}; a refund restores this
* many units to {@code payg_team_extensions.free_units_remaining}. {@code 0} for pre-V19 rows
* and for jobs that drew no free units.
*/
@Column(name = "free_units_consumed", nullable = false)
private Integer freeUnitsConsumed = 0;
@@ -24,7 +24,7 @@ import lombok.extern.slf4j.Slf4j;
* {@code stirling_pdf} (money lives in Stripe).
*
* <p>PAYG prices are plain {@code per_unit} metered prices, so {@code stripe.prices.unit_amount}
* carries the rate directly. The free grant is deliberately NOT in Stripe - it's the one-time
* carries the rate directly. The free grant is deliberately NOT in Stripe - it's the per-period
* {@code pricing_policy.free_tier_units} pool, applied app-side (free units are never metered),
* because un-subscribed teams get the same grant and have no Stripe Price at all.
*
@@ -64,6 +64,7 @@ class TeamBillingServiceMoreTest {
e.setTeamId(TEAM_ID);
e.setPaygSubscriptionId(subscriptionId);
e.setFreeUnitsRemaining(freeRemaining);
e.setFreeUnitsPeriodStart(TeamBillingService.calendarMonthWindow()[0]);
return e;
}
@@ -360,4 +361,132 @@ class TeamBillingServiceMoreTest {
assertThat(window[1]).isEqualTo(YearMonth.now().plusMonths(1).atDay(1).atStartOfDay());
}
}
@Nested
@DisplayName("compute: recurring free grant")
class RecurringFreeGrant {
private static final long GRANT = 500L;
private PaygTeamExtensions stamped(LocalDateTime stamp, long remaining) {
PaygTeamExtensions e = new PaygTeamExtensions();
e.setTeamId(TEAM_ID);
e.setFreeUnitsRemaining(remaining);
e.setFreeUnitsPeriodStart(stamp);
return e;
}
@Test
@DisplayName("a counter stamped with a past period reads as a fresh grant")
void staleStampReadsAsFreshGrant() {
stubGrant(GRANT);
// Nothing has persisted the reset yet, so the read has to show it anyway.
when(extensionsRepository.findById(TEAM_ID))
.thenReturn(
Optional.of(
stamped(
LocalDateTime.now().minusMonths(2).withDayOfMonth(1),
0L)));
TeamBillingContext ctx = service.forTeam(TEAM_ID);
assertThat(ctx.freeGrantUnits()).isEqualTo(GRANT);
assertThat(ctx.freeRemainingUnits()).isEqualTo(GRANT);
}
@Test
@DisplayName("a counter stamped with the current period reads as the stored balance")
void currentStampReadsStoredBalance() {
stubGrant(GRANT);
when(extensionsRepository.findById(TEAM_ID))
.thenReturn(
Optional.of(
stamped(TeamBillingService.calendarMonthWindow()[0], 120L)));
assertThat(service.forTeam(TEAM_ID).freeRemainingUnits()).isEqualTo(120L);
}
@Test
@DisplayName(
"an unstamped row — written before the grant recurred — reads as a fresh grant")
void nullStampReadsAsFreshGrant() {
stubGrant(GRANT);
when(extensionsRepository.findById(TEAM_ID)).thenReturn(Optional.of(stamped(null, 0L)));
assertThat(service.forTeam(TEAM_ID).freeRemainingUnits()).isEqualTo(GRANT);
}
@Test
@DisplayName("the grant resets on the Stripe window, not the calendar month")
void subscribedGrantFollowsTheStripeWindow() {
stubGrant(GRANT);
// Stamped for the calendar month, but this team's period is Stripe-anchored and starts
// mid-month, so the stamp belongs to the previous period and the grant resets.
LocalDateTime stripeStart = LocalDateTime.of(2026, 6, 10, 0, 0);
when(extensionsRepository.findById(TEAM_ID))
.thenReturn(Optional.of(subscribedRow(LocalDateTime.of(2026, 6, 1, 0, 0))));
when(subscriptionDao.findBilling("sub_1"))
.thenReturn(
Optional.of(
new SubscriptionBilling(
stripeStart,
stripeStart.plusMonths(1),
"price_1",
"active",
"usd",
new BigDecimal("2"))));
TeamBillingContext ctx = service.forTeam(TEAM_ID);
assertThat(ctx.periodStart()).isEqualTo(stripeStart);
assertThat(ctx.freeRemainingUnits()).isEqualTo(GRANT);
}
private PaygTeamExtensions subscribedRow(LocalDateTime stamp) {
PaygTeamExtensions e = stamped(stamp, 0L);
e.setPaygSubscriptionId("sub_1");
return e;
}
}
@Nested
@DisplayName("remainingForPeriod")
class RemainingForPeriodRule {
private final LocalDateTime period = LocalDateTime.of(2026, 8, 1, 0, 0);
@Test
@DisplayName("stale stamp yields the full grant; current stamp yields the stored balance")
void staleVersusCurrent() {
assertThat(
TeamBillingService.remainingForPeriod(
period.minusMonths(1), 0L, 500L, period))
.isEqualTo(500L);
assertThat(TeamBillingService.remainingForPeriod(period, 0L, 500L, period)).isZero();
assertThat(TeamBillingService.remainingForPeriod(period, 42L, 500L, period))
.isEqualTo(42L);
}
@Test
@DisplayName("a stamp in the future is never read as another grant")
void futureStampKeepsTheStoredBalance() {
assertThat(TeamBillingService.remainingForPeriod(period.plusDays(1), 7L, 500L, period))
.isEqualTo(7L);
}
@Test
@DisplayName("null stored balance and negative values floor at zero")
void nullAndNegativeBalances() {
assertThat(TeamBillingService.remainingForPeriod(period, null, 500L, period)).isZero();
assertThat(TeamBillingService.remainingForPeriod(period, -5L, 500L, period)).isZero();
assertThat(TeamBillingService.remainingForPeriod(null, 0L, -1L, period)).isZero();
}
@Test
@DisplayName("an unknown current period leaves the counter alone")
void nullCurrentPeriod() {
assertThat(TeamBillingService.isStale(null, null)).isFalse();
assertThat(TeamBillingService.remainingForPeriod(null, 3L, 500L, null)).isEqualTo(3L);
}
}
}
@@ -58,12 +58,14 @@ class TeamBillingServiceTest {
when(pricingPolicyService.getEffectivePolicy(TEAM_ID)).thenReturn(policy);
}
/** Stamped with the current period, so {@code freeRemaining} reads as the live balance. */
private PaygTeamExtensions ext(String subscriptionId, String customerId, long freeRemaining) {
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(TEAM_ID);
ext.setPaygSubscriptionId(subscriptionId);
ext.setStripeCustomerId(customerId);
ext.setFreeUnitsRemaining(freeRemaining);
ext.setFreeUnitsPeriodStart(TeamBillingService.calendarMonthWindow()[0]);
return ext;
}
@@ -31,6 +31,8 @@ import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.web.multipart.MultipartFile;
import stirling.software.saas.payg.billing.TeamBillingContext;
import stirling.software.saas.payg.billing.TeamBillingService;
import stirling.software.saas.payg.bundle.PrepaidBundleService;
import stirling.software.saas.payg.docs.DocumentClassifier;
import stirling.software.saas.payg.docs.DocumentMetrics;
@@ -72,8 +74,13 @@ class JobChargeServiceTest {
private PaygMeterReportingService meterReporter;
private WalletLedgerRepository ledgerRepo;
private PrepaidBundleService prepaidBundleService;
private TeamBillingService teamBillingService;
private JobChargeService service;
private static final LocalDateTime PERIOD_START = LocalDateTime.of(2026, 8, 1, 0, 0);
private static final long GRANT = 500L;
@BeforeEach
void setUp() {
jobService = Mockito.mock(JobService.class);
@@ -90,6 +97,8 @@ class JobChargeServiceTest {
// findByIdForUpdate defaults to Optional.empty() (Mockito) → no free grant consumed unless
// a test stubs the sidecar row. The free split is decided at openProcess time now, not at
// close, so the meter tests just set free_units_consumed on the shadow row directly.
teamBillingService = Mockito.mock(TeamBillingService.class);
when(teamBillingService.forTeam(Mockito.anyLong())).thenReturn(billingContext(GRANT));
service =
new JobChargeService(
jobService,
@@ -100,7 +109,22 @@ class JobChargeServiceTest {
teamExtRepo,
meterReporter,
ledgerRepo,
prepaidBundleService);
prepaidBundleService,
teamBillingService);
}
private static TeamBillingContext billingContext(long grant) {
return new TeamBillingContext(
false,
null,
PERIOD_START,
PERIOD_START.plusMonths(1),
grant,
grant,
null,
null,
null,
null);
}
@AfterEach
@@ -365,6 +389,7 @@ class JobChargeServiceTest {
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(100L);
ext.setFreeUnitsRemaining(10L);
ext.setFreeUnitsPeriodStart(PERIOD_START);
when(teamExtRepo.findByIdForUpdate(100L)).thenReturn(Optional.of(ext));
service.openProcess(
@@ -381,6 +406,96 @@ class JobChargeServiceTest {
verify(teamExtRepo).save(ext);
}
@Test
void openProcess_firstChargeOfNewPeriod_resetsGrantAndRestamps(@TempDir Path tmp)
throws IOException {
// Grant exhausted last period, nothing run since. This charge persists the reset: counter
// back to the full grant, drawn from, and re-stamped so the next charge reads the balance.
PricingPolicy policy = stubPolicy(1, Map.of(JobSource.WEB, 10));
when(policyService.getEffectivePolicy(100L)).thenReturn(policy);
ProcessingJob newJob = openJob(UUID.randomUUID());
when(jobService.joinOrOpen(any(JobContext.class), anyList()))
.thenReturn(new JoinOrOpenResult(newJob, JoinOrOpenResult.Disposition.OPENED));
when(classifier.classify(any(MultipartFile.class), any(Path.class), eq(policy)))
.thenReturn(new DocumentMetrics(50, 1024L, "application/pdf", 4));
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(100L);
ext.setFreeUnitsRemaining(0L);
ext.setFreeUnitsPeriodStart(PERIOD_START.minusMonths(1));
when(teamExtRepo.findByIdForUpdate(100L)).thenReturn(Optional.of(ext));
service.openProcess(
new ChargeContext(
42L, 100L, JobSource.WEB, ProcessType.SINGLE_TOOL, BillingCategory.API),
List.of(jobInput(tmp, "in.pdf", "application/pdf")));
ArgumentCaptor<PaygShadowCharge> captor = ArgumentCaptor.forClass(PaygShadowCharge.class);
verify(shadowRepo).save(captor.capture());
assertThat(captor.getValue().getFreeUnitsConsumed()).isEqualTo(4);
assertThat(ext.getFreeUnitsRemaining()).isEqualTo(GRANT - 4);
assertThat(ext.getFreeUnitsPeriodStart()).isEqualTo(PERIOD_START);
verify(teamExtRepo).save(ext);
}
@Test
void openProcess_unstampedRow_resetsToGrantAndStamps(@TempDir Path tmp) throws IOException {
// An unstamped row must read as owed a reset, not as an exhausted pool.
PricingPolicy policy = stubPolicy(1, Map.of(JobSource.WEB, 10));
when(policyService.getEffectivePolicy(100L)).thenReturn(policy);
ProcessingJob newJob = openJob(UUID.randomUUID());
when(jobService.joinOrOpen(any(JobContext.class), anyList()))
.thenReturn(new JoinOrOpenResult(newJob, JoinOrOpenResult.Disposition.OPENED));
when(classifier.classify(any(MultipartFile.class), any(Path.class), eq(policy)))
.thenReturn(new DocumentMetrics(50, 1024L, "application/pdf", 1));
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(100L);
ext.setFreeUnitsRemaining(0L);
when(teamExtRepo.findByIdForUpdate(100L)).thenReturn(Optional.of(ext));
service.openProcess(
new ChargeContext(
42L, 100L, JobSource.WEB, ProcessType.SINGLE_TOOL, BillingCategory.API),
List.of(jobInput(tmp, "in.pdf", "application/pdf")));
ArgumentCaptor<PaygShadowCharge> captor = ArgumentCaptor.forClass(PaygShadowCharge.class);
verify(shadowRepo).save(captor.capture());
assertThat(captor.getValue().getFreeUnitsConsumed()).isEqualTo(1);
assertThat(ext.getFreeUnitsRemaining()).isEqualTo(GRANT - 1);
assertThat(ext.getFreeUnitsPeriodStart()).isEqualTo(PERIOD_START);
}
@Test
void openProcess_zeroGrantRollover_stampsWithoutDrawing(@TempDir Path tmp) throws IOException {
// A zero grant still advances the stamp, or every later charge re-evaluates a stale row.
when(teamBillingService.forTeam(100L)).thenReturn(billingContext(0L));
PricingPolicy policy = stubPolicy(1, Map.of(JobSource.WEB, 10));
when(policyService.getEffectivePolicy(100L)).thenReturn(policy);
ProcessingJob newJob = openJob(UUID.randomUUID());
when(jobService.joinOrOpen(any(JobContext.class), anyList()))
.thenReturn(new JoinOrOpenResult(newJob, JoinOrOpenResult.Disposition.OPENED));
when(classifier.classify(any(MultipartFile.class), any(Path.class), eq(policy)))
.thenReturn(new DocumentMetrics(50, 1024L, "application/pdf", 3));
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(100L);
ext.setFreeUnitsRemaining(0L);
when(teamExtRepo.findByIdForUpdate(100L)).thenReturn(Optional.of(ext));
service.openProcess(
new ChargeContext(
42L, 100L, JobSource.WEB, ProcessType.SINGLE_TOOL, BillingCategory.API),
List.of(jobInput(tmp, "in.pdf", "application/pdf")));
ArgumentCaptor<PaygShadowCharge> captor = ArgumentCaptor.forClass(PaygShadowCharge.class);
verify(shadowRepo).save(captor.capture());
assertThat(captor.getValue().getFreeUnitsConsumed()).isZero();
assertThat(ext.getFreeUnitsRemaining()).isZero();
assertThat(ext.getFreeUnitsPeriodStart()).isEqualTo(PERIOD_START);
verify(teamExtRepo).save(ext);
}
@Test
void openProcess_grantStraddle_drawsRemainderFreeAndBillsTheRest(@TempDir Path tmp)
throws IOException {
@@ -396,6 +511,7 @@ class JobChargeServiceTest {
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(100L);
ext.setFreeUnitsRemaining(3L);
ext.setFreeUnitsPeriodStart(PERIOD_START);
when(teamExtRepo.findByIdForUpdate(100L)).thenReturn(Optional.of(ext));
service.openProcess(
@@ -426,6 +542,7 @@ class JobChargeServiceTest {
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(100L);
ext.setFreeUnitsRemaining(0L);
ext.setFreeUnitsPeriodStart(PERIOD_START);
when(teamExtRepo.findByIdForUpdate(100L)).thenReturn(Optional.of(ext));
service.openProcess(
@@ -544,8 +661,8 @@ class JobChargeServiceTest {
assertThat(refund.getReferenceId()).isEqualTo(jobId.toString());
assertThat(refund.getPolicyId()).isEqualTo(7L);
assertThat(refund.getBillingCategory()).isEqualTo(BillingCategory.API);
// This row consumed no free units, so the grant counter is left alone.
verify(teamExtRepo, never()).restoreFreeUnits(eq(100L), Mockito.anyLong());
// No free units consumed, so the grant counter is never loaded.
verify(teamExtRepo, never()).findByIdForUpdate(100L);
}
@Test
@@ -556,10 +673,35 @@ class JobChargeServiceTest {
PaygShadowCharge row = chargedShadowRow(jobId, 100L, 10, 3, BillingCategory.API);
when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.of(row));
when(jobRepo.findById(jobId)).thenReturn(Optional.of(openJob(jobId)));
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(100L);
ext.setFreeUnitsRemaining(GRANT - 3);
ext.setFreeUnitsPeriodStart(PERIOD_START);
when(teamExtRepo.findByIdForUpdate(100L)).thenReturn(Optional.of(ext));
service.markFirstStepFailed(jobId, "first-step-5xx:503");
verify(teamExtRepo).restoreFreeUnits(100L, 3L);
assertThat(ext.getFreeUnitsRemaining()).isEqualTo(GRANT);
verify(teamExtRepo).save(ext);
}
@Test
void markFirstStepFailed_refundAfterPeriodTurned_doesNotExceedTheGrant() {
// The charge's period is over and the grant already reset, so the restore is capped.
UUID jobId = UUID.randomUUID();
PaygShadowCharge row = chargedShadowRow(jobId, 100L, 10, 3, BillingCategory.API);
when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId)).thenReturn(Optional.of(row));
when(jobRepo.findById(jobId)).thenReturn(Optional.of(openJob(jobId)));
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(100L);
ext.setFreeUnitsRemaining(0L);
ext.setFreeUnitsPeriodStart(PERIOD_START.minusMonths(1));
when(teamExtRepo.findByIdForUpdate(100L)).thenReturn(Optional.of(ext));
service.markFirstStepFailed(jobId, "first-step-5xx:503");
assertThat(ext.getFreeUnitsRemaining()).isEqualTo(GRANT);
assertThat(ext.getFreeUnitsPeriodStart()).isEqualTo(PERIOD_START);
}
@Test
@@ -886,6 +1028,7 @@ class JobChargeServiceTest {
ext.setStripeCustomerId("cus_x");
ext.setPaygSubscriptionId("sub_x");
ext.setFreeUnitsRemaining(0L);
ext.setFreeUnitsPeriodStart(PERIOD_START);
when(teamExtRepo.findByIdForUpdate(teamId)).thenReturn(Optional.of(ext));
when(teamExtRepo.findById(teamId)).thenReturn(Optional.of(ext));
when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId))
@@ -930,6 +1073,7 @@ class JobChargeServiceTest {
PaygTeamExtensions ext = new PaygTeamExtensions();
ext.setTeamId(teamId);
ext.setFreeUnitsRemaining(50L);
ext.setFreeUnitsPeriodStart(PERIOD_START);
when(teamExtRepo.findByIdForUpdate(teamId)).thenReturn(Optional.of(ext));
when(teamExtRepo.findById(teamId)).thenReturn(Optional.of(ext));
when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId))
@@ -974,6 +1118,7 @@ class JobChargeServiceTest {
ext.setStripeCustomerId("cus_x");
ext.setPaygSubscriptionId("sub_x");
ext.setFreeUnitsRemaining(0L);
ext.setFreeUnitsPeriodStart(PERIOD_START);
when(teamExtRepo.findByIdForUpdate(teamId)).thenReturn(Optional.of(ext));
when(teamExtRepo.findById(teamId)).thenReturn(Optional.of(ext));
when(shadowRepo.findFirstByJobIdOrderByIdAsc(jobId))
@@ -1032,8 +1177,6 @@ class JobChargeServiceTest {
return row;
}
// --- helpers --------------------------------------------------------------------------------
private static PricingPolicy stubPolicy(int minCharge, Map<JobSource, Integer> stepLimits) {
PricingPolicy p = new PricingPolicy();
p.setId(42L);
@@ -26,8 +26,8 @@ import stirling.software.saas.payg.repository.WalletPolicyRepository;
import stirling.software.saas.payg.wallet.WalletPolicy;
/**
* Unit tests for {@link EntitlementService}. Two branches (design 2026-06-11 — the free allowance
* is a one-time lifetime grant):
* Unit tests for {@link EntitlementService}. Two branches (the free allowance is a per-period
* grant, projected onto the current period by {@code TeamBillingService} before it gets here):
*
* <ul>
* <li><b>Unsubscribed</b> — gated by the grant. Cap = grant size, spend = {@code grant
@@ -96,7 +96,6 @@ class EntitlementServiceTest {
assertThat(snap.periodCapUnits()).isEqualTo(2000L);
assertThat(snap.periodSpendUnits()).isEqualTo(500L);
// 500/2000 = 25% — FULL
assertThat(snap.state()).isEqualTo(EntitlementState.FULL);
}
+4 -1
View File
@@ -30,7 +30,7 @@ from stirling.contracts import (
)
from stirling.contracts.pdf_create import PdfCreateOrchestrateResponse
from stirling.models import ApiModel
from stirling.services import AppRuntime
from stirling.services import AppRuntime, language_directive, set_reply_locale
logger = logging.getLogger(__name__)
@@ -153,6 +153,8 @@ class OrchestratorAgent:
)
async def handle(self, request: OrchestratorRequest) -> OrchestratorResponse:
# Bound once; delegates and worker tasks inherit it.
set_reply_locale(request.locale)
logger.info(
"[orchestrator] handle: files=%s resume_with=%s artifacts=%s msg=%r",
[file.name for file in request.files],
@@ -270,6 +272,7 @@ class OrchestratorAgent:
f"User message: {request.user_message}\n"
f"Files: {format_file_names(request.files)}\n"
f"Available artifacts:\n{artifact_summary}"
f"\n{language_directive()}"
)
def _describe_artifacts(self, request: OrchestratorRequest) -> str:
@@ -30,7 +30,7 @@ from stirling.contracts.pdf_comments import (
)
from stirling.logging import Pretty
from stirling.models import ApiModel
from stirling.services import AppRuntime
from stirling.services import AppRuntime, language_directive
logger = logging.getLogger(__name__)
@@ -160,6 +160,8 @@ class PdfCommentAgent:
]
for index, chunk in enumerate(request.chunks):
lines.append(f"[{index}] page={chunk.page + 1} text={json.dumps(chunk.text)}")
# Last, after the untrusted chunk text.
lines.append(f"\n{language_directive()}")
return "\n".join(lines)
@staticmethod
@@ -45,7 +45,7 @@ from stirling.contracts.pdf_create import (
WrittenSections,
)
from stirling.models.agent_tool_models import AgentToolId, CreatePdfFromHtmlAgentParams
from stirling.services import AppRuntime
from stirling.services import AppRuntime, language_directive
logger = logging.getLogger(__name__)
@@ -260,6 +260,7 @@ def _build_sections_prompt(meta: DocumentMeta, user_request: str, history: str)
lines.append(f"\nConversation history:\n{history}")
lines.append(f"\nUser request: {user_request}")
lines.append(f"\n{language_directive()}")
return "\n".join(lines)
@@ -292,6 +293,7 @@ def _build_writer_prompt(plan: DocumentPlan, chunk: _Chunk) -> str:
for point in s.key_points:
lines.append(f" - {point}")
lines.append(f"\n{language_directive()}")
return "\n".join(lines)
@@ -338,6 +340,7 @@ class PdfCreateAgent:
# ── Phase 1: plan meta ─────────────────────────────────────────────────
logger.info("[pdf-create] phase 1/6: planning document meta")
meta_prompt = f"Conversation history:\n{history}\n\nUser request: {request.user_message}"
meta_prompt += f"\n\n{language_directive()}"
meta_result = await self._meta_planner.run(meta_prompt)
meta = meta_result.output
+2 -1
View File
@@ -27,7 +27,7 @@ from stirling.contracts import (
)
from stirling.logging import Pretty
from stirling.models import OPERATIONS, ApiModel, ParamToolModel, ToolEndpoint
from stirling.services import AppRuntime, ToolChainStep, blocking, validate_tool_chain
from stirling.services import AppRuntime, ToolChainStep, blocking, language_directive, validate_tool_chain
logger = logging.getLogger(__name__)
@@ -357,6 +357,7 @@ class PdfEditAgent:
f"{unavailable_line}"
f"{repair_line}"
f"Extracted page text:\n{format_page_text(request.page_text)}"
f"\n{language_directive()}"
)
# Endpoints that exist on the server and are callable via the direct API or the manual UI,
+3 -1
View File
@@ -29,7 +29,7 @@ from stirling.contracts import (
from stirling.documents import RagCapability
from stirling.models import PrincipalId
from stirling.models.agent_tool_models import AgentToolId, MathAuditorAgentParams
from stirling.services import AppRuntime, require_current_user_id
from stirling.services import AppRuntime, language_directive, require_current_user_id
logger = logging.getLogger(__name__)
@@ -223,6 +223,7 @@ class PdfQuestionAgent:
forbids invented figures; the LLM only restates Verdict facts.
"""
prompt = f"User question:\n{user_message}\n\nMath audit Verdict (JSON):\n{verdict.model_dump_json()}"
prompt += f"\n\n{language_directive()}"
result = await self._math_synth_agent.run(prompt)
return result.output
@@ -233,4 +234,5 @@ class PdfQuestionAgent:
f"Files: {format_file_names(request.files)}\n"
f"Question: {request.question}\n"
"Pick the right retrieval tool for this question, then answer from what it returns."
f"\n{language_directive()}"
)
+3 -1
View File
@@ -56,7 +56,7 @@ from stirling.models.agent_tool_models import (
PdfCommentAgentParams,
)
from stirling.models.tool_models import AddCommentsParams
from stirling.services import AppRuntime, require_current_user_id
from stirling.services import AppRuntime, language_directive, require_current_user_id
# Fallback right-margin placement used when a finding has no usable
# anchor text. A4/Letter portrait assumed.
@@ -209,6 +209,7 @@ class PdfReviewAgent:
placement geometry to produce the JSON the ``add-comments`` tool wants.
"""
prompt = f"User review request:\n{user_message}\n\nMath audit Verdict (JSON):\n{verdict.model_dump_json()}"
prompt += f"\n\n{language_directive()}"
result = await self._localiser_agent.run(prompt)
specs = self._build_comment_specs(verdict, result.output.comments)
serialised = [spec.model_dump(by_alias=True, exclude_none=True) for spec in specs]
@@ -238,6 +239,7 @@ class PdfReviewAgent:
prompt = (
f"<user_message>{_escape_for_tag(user_message)}</user_message>\n"
f"<verdict>{_escape_for_tag(report.model_dump_json())}</verdict>"
f"\n{language_directive()}"
)
result = await self._contradiction_localiser.run(prompt)
specs = self._build_paired_comment_specs(report, result.output.comments)
+3 -1
View File
@@ -21,7 +21,7 @@ from stirling.contracts import (
format_conversation_history,
)
from stirling.models import ApiModel
from stirling.services import AppRuntime
from stirling.services import AppRuntime, language_directive
class UserSpecMetadata(ApiModel):
@@ -98,6 +98,7 @@ class UserSpecAgent:
f"Edit plan summary:\n{edit_plan.summary}\n\n"
f"Edit plan rationale:\n{edit_plan.rationale or 'None'}\n\n"
f"Edit plan steps:\n{edit_plan.model_dump_json(indent=2)}"
f"\n\n{language_directive()}"
)
def _build_revision_prompt(self, request: AgentRevisionRequest, edit_plan: EditPlanResponse) -> str:
@@ -108,6 +109,7 @@ class UserSpecAgent:
f"Edit plan summary:\n{edit_plan.summary}\n\n"
f"Edit plan rationale:\n{edit_plan.rationale or 'None'}\n\n"
f"Edit plan steps:\n{edit_plan.model_dump_json(indent=2)}"
f"\n\n{language_directive()}"
)
async def _build_edit_plan(
@@ -42,6 +42,8 @@ class OrchestratorRequest(ApiModel):
conversation_history: list[ConversationMessage] = Field(default_factory=list)
artifacts: list[WorkflowArtifact] = Field(default_factory=list)
resume_with: SupportedCapability | None = None
# Reply language (IETF tag); unset falls back to the message's own language.
locale: str | None = None
# See `PdfEditRequest.enabled_endpoints`.
enabled_endpoints: Annotated[list[ToolEndpoint], BeforeValidator(drop_unknown_tool_endpoints)] = Field(
default_factory=list
+3
View File
@@ -1,5 +1,6 @@
"""Shared services used by the Stirling AI runtime."""
from .language import language_directive, set_reply_locale
from .progress import (
ProgressEmitter,
emit_progress,
@@ -20,9 +21,11 @@ __all__ = [
"build_runtime",
"current_user_id",
"emit_progress",
"language_directive",
"require_current_user_id",
"reset_progress_emitter",
"set_progress_emitter",
"set_reply_locale",
"setup_posthog_tracking",
"validate_tool_chain",
]
+23
View File
@@ -0,0 +1,23 @@
"""Per-request reply language, bound by the orchestrator, read by prompt builders."""
from __future__ import annotations
from contextvars import ContextVar
_locale: ContextVar[str | None] = ContextVar("stirling_reply_locale", default=None)
def set_reply_locale(locale: str | None) -> None:
_locale.set(locale)
def language_directive() -> str:
"""Prompt line pinning the reply language; append to any user-facing prompt."""
locale = _locale.get()
if not locale:
return "Write anything the user will read in the same language as their message."
return (
f"Write anything the user will read in the language of locale '{locale}', whatever "
"language this prompt, the documents, or the tool output are in. Only a different "
"language the user explicitly asks for overrides this."
)
+66
View File
@@ -0,0 +1,66 @@
from __future__ import annotations
from collections.abc import Iterator
from typing import Any, cast
import pytest
from stirling.agents import OrchestratorAgent
from stirling.agents.pdf_questions import PdfQuestionAgent
from stirling.contracts import (
OrchestratorRequest,
PdfQuestionAnswerResponse,
PdfQuestionRequest,
SupportedCapability,
)
from stirling.services import language_directive, set_reply_locale
from stirling.services.runtime import AppRuntime
@pytest.fixture(autouse=True)
def reset_locale() -> Iterator[None]:
set_reply_locale(None)
yield
set_reply_locale(None)
def test_directive_falls_back_to_the_message_language() -> None:
assert "same language as their message" in language_directive()
def test_directive_pins_the_bound_locale() -> None:
set_reply_locale("fr-FR")
assert "'fr-FR'" in language_directive()
def test_orchestrator_request_carries_the_locale() -> None:
assert OrchestratorRequest.model_validate({"userMessage": "hi", "locale": "de-DE"}).locale == "de-DE"
assert OrchestratorRequest.model_validate({"userMessage": "hi"}).locale is None
def test_question_prompt_carries_the_directive() -> None:
set_reply_locale("es-ES")
# _build_prompt ignores self, so call it off the class.
prompt = PdfQuestionAgent._build_prompt(cast(Any, None), PdfQuestionRequest(question="¿Cuántas páginas?"))
assert "'es-ES'" in prompt
@pytest.mark.anyio
async def test_handle_binds_the_locale_for_delegates(runtime: AppRuntime, monkeypatch: pytest.MonkeyPatch) -> None:
"""The resume path reaches a delegate with the request's locale already bound."""
agent = OrchestratorAgent(runtime)
seen: list[str] = []
async def capture(request: OrchestratorRequest) -> PdfQuestionAnswerResponse:
seen.append(language_directive())
return PdfQuestionAnswerResponse(answer="ok")
monkeypatch.setattr(agent, "_run_pdf_question", capture)
await agent.handle(
OrchestratorRequest(
user_message="Combien de pages ?",
locale="fr-FR",
resume_with=SupportedCapability.PDF_QUESTION,
)
)
assert "'fr-FR'" in seen[0]
@@ -4321,7 +4321,8 @@ download = "Download"
downloadAll = "Download all"
downloadVersion = "Download this version"
dropOverlay = "Drop files to upload"
dropOverlaySub = "Files start in Local. Use 'Move to' or 'Save to cloud' to organize them into a folder."
dropOverlaySub = "Files land in Local. Organize them into folders any time."
dropOverlaySubFolder = "They'll be added to this folder."
duplicate = "Duplicate"
file = "File"
fileInfo = "File info"
@@ -4334,12 +4335,19 @@ inPath = "in {{path}}"
inWorkspace = "Open"
inWorkspaceAria = "Already in workspace"
loading = "Loading…"
localFoldersUnavailable = "Folders are cloud-only - save a file to the cloud to organize it."
localFolderManagedByDisk = "This folder is managed by its directory on disk."
moveAcrossKindsBlocked = "These folders live in different places, so one can't go inside the other."
moveIntoMountCloudSkipped_one = "{{count}} server file stayed in your files. It lives on the server, not on this disk."
moveIntoMountCloudSkipped_other = "{{count}} server files stayed in your files. They live on the server, not on this disk."
moveIntoMountFailed_one = "{{count}} file could not be written into the folder."
moveIntoMountFailed_other = "{{count}} files could not be written into the folder."
moveIntoVirtualCloudSkipped_one = "{{count}} server file was left in place. Server files can't live in browser-only folders."
moveIntoVirtualCloudSkipped_other = "{{count}} server files were left in place. Server files can't live in browser-only folders."
moveSkippedRemote_one = "{{count}} file couldn't be moved on the server (no permission or already deleted)."
moveSkippedRemote_other = "{{count}} files couldn't be moved on the server (no permission or already deleted)."
moveTo = "Move to…"
newFolder = "New folder"
newFolderStorageDisabled = "Server folder storage isn't enabled. Ask your admin to turn it on."
newFolderStorageDisabled = "Server folder storage isn't enabled."
newFolderTabUnavailable = "Switch to All or Cloud to create folders."
offlineNoFolderEdits = "Server folder sync unavailable - folder changes are disabled. Check sign-in and storage configuration."
open = "Open"
@@ -4347,6 +4355,7 @@ openVersionInWorkspace = "Open in workspace"
originFilter = "Filter by source"
refresh = "Refresh from server"
remove = "Delete"
removeLocalFolder = "Remove (files stay on disk)"
removeVersion = "Remove this version"
rename = "Rename"
renamed = "Renamed"
@@ -4359,6 +4368,7 @@ selectAll = "Select all"
selectAllHint = "Click to select all. Tip: hold Ctrl (or Cmd) to add files one at a time, Shift to select a range."
selectedCount = "{{count}} selected"
selectFile = "Select file {{name}}"
serverFolderNeedsConnection = "Sign in to Stirling Cloud or connect a self-hosted server to use server folders."
shareDisabledHint = "File sharing isn't enabled on this server. Ask your admin to enable it."
shareManage = "Manage sharing"
showDetails = "Show details"
@@ -4367,7 +4377,6 @@ summary_one = "{{count}} item"
summary_other = "{{count}} items"
tree = "Folders"
upload = "Upload"
uploadedToLocal = "Uploaded files start in Local. Use 'Save to cloud' to put them in a folder."
uploadFromMobile = "Upload from Mobile"
versionActions = "Version actions"
versionCollapse = "Collapse middle versions"
@@ -4400,7 +4409,6 @@ everywhereHint = "Deletes the file from this device and the cloud."
[filesPage.empty]
hint = "Drop PDFs anywhere on this page to upload, or use the New folder button to organize your files."
newFolderCta = "Create folder"
title = "This folder is empty"
uploadCta = "Upload files"
@@ -4410,10 +4418,6 @@ offlineHint = "Reconnect to load your cloud library."
offlineTitle = "No cached cloud files"
title = "No cloud files yet"
[filesPage.empty.local]
hint = "Files saved without uploading stay here. Drop a file to add one."
title = "No local-only files"
[filesPage.empty.noResults]
hint = "No files in this folder match your filter. Try a different term or clear the filter."
title = "No matching files"
@@ -4433,6 +4437,8 @@ title = "You haven't shared any files yet"
[filesPage.error]
actionFailed = "Could not {{action}}."
actionFailedDetail = "Could not {{action}}: {{message}}"
addFolderFailed = "Could not add the folder."
addFolderFailedDetail = "Could not add the folder: {{message}}"
cloudDeleteFailed_one = "Couldn't delete 1 file from the cloud."
cloudDeleteFailed_other = "Couldn't delete {{count}} files from the cloud."
deleteFolderFailed = "Could not delete folder."
@@ -4445,8 +4451,14 @@ moveFilesFailed = "Could not move files."
moveFilesFailedDetail = "Could not move files: {{message}}"
moveFolderFailed = "Could not move folder."
moveFolderFailedDetail = "Could not move folder: {{message}}"
openDiskFileFailed = "Could not open {{name}}."
openDiskFileFailedDetail = "Could not open {{name}}: {{message}}"
readFolderFailed = "Could not read the folder."
readFolderFailedDetail = "Could not read the folder: {{message}}"
removeFilesFailed = "Could not remove files."
removeFilesFailedDetail = "Could not remove files: {{message}}"
removeFolderFailed = "Could not remove folder."
removeFolderFailedDetail = "Could not remove folder: {{message}}"
uploadFilesFailed = "Could not upload files."
uploadFilesFailedDetail = "Could not upload files: {{message}}"
@@ -4466,12 +4478,21 @@ activeCount = "{{count}} filters active"
clearAll = "Clear filters"
label = "Filters"
[filesPage.folderKind]
local = "Local folder"
virtual = "Browser folder"
[filesPage.folderName]
cancel = "Cancel"
error = "Could not save folder. Try again."
label = "Folder name"
placeholder = "Folder name"
[filesPage.folderOrigin]
diskHint = "A folder mounted from a directory on your disk"
serverHint = "A folder stored on the Stirling server"
virtualHint = "A folder that lives only in this browser"
[filesPage.moveDialog]
cancel = "Cancel"
confirm = "Move here"
@@ -4485,10 +4506,16 @@ newFolderPlaceholder = "Folder name"
newFolderToggle = "Create new folder…"
title = "Move to folder"
[filesPage.newFolderMenu]
addExisting = "Add local folder"
server = "New folder on the server"
serverHint = "Synced to your account, available wherever you sign in."
[filesPage.origin]
all = "All sources"
cloud = "Cloud"
cloudHint = "Stored on the Stirling server"
diskHint = "A file in the mounted folder on your disk"
local = "Local"
localHint = "Only stored in this browser"
shared = "Shared"
@@ -5416,39 +5443,45 @@ sort = "Sort"
title = "Merge Settings Overview"
[mobileScanner]
addToBatch = "Add to Batch"
addMore = "Add More"
back = "Back"
batchImages = "Batch"
camera = "Camera"
cameraAccessDenied = "Camera access denied. Please enable camera access."
cameraDescription = "Scan documents using your device camera with automatic edge detection"
capture = "Capture Photo"
chooseMethod = "Choose Upload Method"
chooseMethodDescription = "Select how you want to scan and upload documents"
clearBatch = "Clear"
clearAll = "Clear All"
closeTabHint = "You can close this tab now."
dismiss = "Dismiss"
edgeDetection = "Edge Detection"
fileDescription = "Upload existing photos or documents from your device"
fileReadFailed = "Could not read that file."
fileUpload = "File Upload"
flash = "Flash"
flashlight = "Flashlight"
httpsRequired = "Camera access requires HTTPS or localhost. Please use HTTPS or access via localhost."
noSession = "Invalid Session"
imageCount_one = "{{count}} image"
imageCount_other = "{{count}} images"
imagePosition = "Image {{index}} of {{total}}"
invalidFileType = "Please choose an image file."
noSessionMessage = "Please scan a valid QR code to access this page."
processing = "Processing..."
remove = "Remove"
retake = "Retake"
scanAnother = "Scan another"
selectFilesPrompt = "Select files to upload"
selectImage = "Select Image"
selectImages = "Select Images"
sessionExpired = "This session has expired. Please refresh and try again."
sessionInvalid = "Session Error"
sessionNotFound = "Session not found. Please refresh and try again."
sessionValidationError = "Unable to verify session. Please try again."
startingCamera = "Starting camera…"
title = "Mobile Scanner"
upload = "Upload"
uploadAll = "Upload All"
uploadFailed = "Upload failed. Please try again."
uploading = "Uploading..."
uploadSuccess = "Upload Successful!"
uploadSuccessMessage = "Your images have been transferred."
uploadWithCount = "Upload ({{total}})"
validating = "Validating session..."
[mobileSign]
@@ -6091,7 +6124,7 @@ freeTitle = "Unlimited PDF editing"
[payg.free.hero]
barAria = "Free PDFs remaining"
capSuffix = "of {{limit}} free PDFs left"
capSuffix = "of {{limit}} free PDFs left this month"
metaCategories = "Automation · AI · API requests"
[payg.free.member]
@@ -6157,7 +6190,7 @@ leader = "Team owner"
member = "Member"
[payg.signupRequired]
body = "Stirling PDF gives every signed-up account 500 free operations, enough to keep most workflows humming without paying a cent. You're currently using Stirling as a guest, which doesn't include billable tools like AI, automations, or hosted processing."
body = "Stirling PDF gives every signed-up account 500 free operations a month, enough to keep most workflows humming without paying a cent. You're currently using Stirling as a guest, which doesn't include billable tools like AI, automations, or hosted processing."
cancel = "Not now"
cta = "Sign up free"
subtext = "Creating an account is free and takes a few seconds. No credit card required."
@@ -6843,7 +6876,7 @@ name = "Free"
[plan.freeLimit]
cta = "View Processor Plan"
dismiss = "Maybe Later"
message = "That's your whole free allowance for automation, AI and the API. Seriously impressive! Keep the momentum going for just pennies a day."
message = "That's your whole free allowance for automation, AI and the API this month. Seriously impressive! It resets next month, or keep the momentum going now for just pennies a day."
title = "Woah, {{total}} PDFs Processed!"
[plan.highlights]
@@ -7425,17 +7458,17 @@ reachedTitle = "Monthly spend limit reached"
title = "Couldn't open Stripe portal"
[portal.billing.walletMeter]
barAria = "Free PDFs remaining"
capSuffix_one = "of {{allowance}} free PDF left"
capSuffix_other = "of {{allowance}} free PDFs left"
barAria = "Free credits remaining"
capSuffix_one = "of {{allowance}} free credit left this month"
capSuffix_other = "of {{allowance}} free credits left this month"
eyebrow = "Processor trial"
statusLabel_one = "{{used}} used"
statusLabel_other = "{{used}} used"
sub = "Use the PDF Editor for free. Pay to process PDFs automatically."
title_one = "{{allowance}} free credit to start"
title_other = "{{allowance}} free credits to start"
titleWithRate_one = "{{allowance}} free credit, then {{rate}} per PDF"
titleWithRate_other = "{{allowance}} free credits, then {{rate}} per PDF"
title_one = "{{allowance}} free credit every month"
title_other = "{{allowance}} free credits every month"
titleWithRate_one = "{{allowance}} free credit every month, then {{rate}} per PDF"
titleWithRate_other = "{{allowance}} free credits every month, then {{rate}} per PDF"
[portal.components.billingUnit]
approval = "approval"
@@ -8420,11 +8453,14 @@ platform = "PDF Platform"
processor = "PDF Processor"
[portal.pipelines]
subtitle = "Every automated document pipeline on the backend: an ordered chain of operations over a set of sources, run on a trigger. Click a row for its steps and sources."
subtitle = "Automate your document workflows. Start from a template for a simple, guided setup, or build a custom pipeline from scratch. Enforce any pipeline as a policy to run it on every document."
title = "Pipelines"
[portal.pipelines.actions]
newPipeline = "New pipeline"
newCustomPipeline = "New custom pipeline"
[portal.pipelines.all]
title = "All pipelines"
[portal.pipelines.builder]
activate = "Activate"
@@ -8483,6 +8519,9 @@ output-uncertain = "May not run: output depends on setup"
source-mismatch = "Input is {{produced}}, needs {{accepts}}"
undeclared-operation = "Can't check what this step accepts"
[portal.pipelines.builder.icon]
label = "Change icon"
[portal.pipelines.composer]
addTool = "Add a tool"
create = "Create pipeline"
@@ -8532,6 +8571,11 @@ connectSource = "Connect a source"
description = "Create your first pipeline: pick the sources it runs over, chain the operations, and choose where output goes."
title = "No pipelines yet"
[portal.pipelines.enforce]
desc = "Runs automatically; members can't turn it off"
info = "What enforcing as a policy means"
label = "Enforce as policy"
[portal.pipelines.graph]
addFirstTool = "Add a tool"
dragHint = "Drop on a line to move it"
@@ -8591,6 +8635,11 @@ sources = "Sources"
status = "Status"
steps = "Steps"
trigger = "Trigger"
type = "Type"
[portal.pipelines.templates]
setUp = "Set up"
title = "Templates"
[portal.pipelines.trigger]
editor-export = "Every export"
@@ -8599,10 +8648,12 @@ folder-watch = "Folder watch"
manual = "Manual"
schedule = "Scheduled"
[portal.pipelines.type]
pipeline = "Pipeline"
policy = "Policy"
[portal.policies]
defaultName = "{{category}} Policy"
subtitle = "Standing automations that enforce a tool pipeline on every document. Each policy fires on upload or export, runs its tool chain, and saves the enforced version alongside the original."
title = "Policies"
defaultName = "{{category}} Pipeline"
[portal.policies.card]
comingSoon = "Upgrade to Enterprise"
@@ -8728,7 +8779,7 @@ summary = "Detects and redacts PII, strips active content (JavaScript), and wate
2 = "Watermark"
[portal.policies.detail]
enforces = "Enforces"
enforces = "Steps"
onEveryExport = "On every export"
onEveryUpload = "On every upload"
outputAsNewFile = "as a new file"
@@ -8748,13 +8799,13 @@ resume = "Resume"
runNow = "Run now"
[portal.policies.detail.clearHistory]
body = "This policy will forget every file it has already processed and reprocess everything currently in its sources on the next run. The files themselves are not changed. This cannot be undone."
body = "This pipeline will forget every file it has already processed and reprocess everything currently in its sources on the next run. The files themselves are not changed. This cannot be undone."
cancel = "Cancel"
confirm = "Clear history"
title = "Clear processed history?"
[portal.policies.detail.emptyActivity]
description = "Documents will appear here once this policy runs."
description = "Documents will appear here once this pipeline runs."
title = "No activity yet"
[portal.policies.endpoints]
@@ -8766,11 +8817,6 @@ flatten = "Flatten"
ocrPdf = "OCR"
sanitizePdf = "Remove JavaScript"
[portal.policies.offline]
description = "Your policies are saved and will appear once the connection is restored."
retry = "Retry"
title = "Backend unavailable"
[portal.policies.operations]
change = "Change what this step does"
noResults = "No step matches that. Try a product name, or \"scan\", \"notify\", \"attach\"."
@@ -8969,7 +9015,7 @@ label = "Trigger a Zap or Make scenario"
[portal.policies.stats]
activeFor = "Active"
dataProcessed = "Data processed"
docsEnforced = "Docs enforced"
docsEnforced = "Docs processed"
[portal.policies.status]
active = "Active"
@@ -8999,10 +9045,9 @@ policy = "Policy"
status = "Status"
[portal.policies.wizard.actions]
back = "Back"
cancel = "Cancel"
continue = "Continue"
enablePolicy = "Enable policy"
customise = "Customise"
enablePolicy = "Create pipeline"
saveChanges = "Save changes"
[portal.policies.wizard.capability.classify]
@@ -9043,47 +9088,14 @@ labelsHeading = "Classification labels"
[portal.policies.wizard.errors]
noTools = "Enable at least one tool in the workflow first."
saveFailed = "Couldn't save the policy. Please try again."
[portal.policies.wizard.output]
heading = "Output & run"
[portal.policies.wizard.output.filenameRule]
autoNumber = "Auto-number"
label = "Filename rule"
placeholder = "Text to add (optional)"
prefix = "Prefix"
suffix = "Suffix"
[portal.policies.wizard.output.outputAs]
label = "Output as"
newFile = "New file"
newVersion = "New version"
[portal.policies.wizard.output.runOn]
export = "Export"
helper = "When the policy fires: on upload, or before export."
label = "Run on"
upload = "Upload"
[portal.policies.wizard.settings]
heading = "Settings"
[portal.policies.wizard.sources]
heading = "Sources"
loading = "Loading sources…"
[portal.policies.wizard.tabs]
ariaLabel = "Setup steps"
settings = "Settings"
workflow = "Actions"
saveFailed = "Couldn't save the pipeline. Please try again."
[portal.policies.wizard.title]
edit = "Edit {{category}} policy"
setUp = "Set up {{category}} policy"
edit = "Edit {{category}} pipeline"
setUp = "Set up {{category}} pipeline"
[portal.policies.wizard.workflow]
description = "Choose what this policy does to every document it processes."
description = "Choose what this pipeline does to every document it processes."
[portal.policySummary.action]
setUp = "Set up"
@@ -39,6 +39,18 @@
"identifier": "fs:allow-read-file",
"allow": [{ "path": "**" }]
},
{
"identifier": "fs:allow-read-dir",
"allow": [{ "path": "**" }]
},
{
"identifier": "fs:allow-stat",
"allow": [{ "path": "**" }]
},
{
"identifier": "fs:allow-mkdir",
"allow": [{ "path": "**" }]
},
{
"identifier": "fs:allow-write-file",
"allow": [{ "path": "**" }]
@@ -5,7 +5,7 @@ import {
} from "@app/components/onboarding/onboardingSlideTypes";
export interface SaasFlowInputs {
/** Free-tier wallet with one-time allowance remaining — show the usage meter. */
/** Free-tier wallet with allowance remaining this period — show the usage meter. */
showUsageSlide: boolean;
/** Team leaders only — invited members and anonymous guests skip the team slide. */
showTeamSlide: boolean;
@@ -138,7 +138,7 @@ export function FreeLimitReachedModal({ onClose }: FreeLimitReachedModalProps) {
<div className={`${styles.bodyCopy} ${styles.bodyCopyInner}`}>
{t(
"plan.freeLimit.message",
"That's your whole free allowance for automation, AI and the API. Seriously impressive! Keep the momentum going for just pennies a day.",
"That's your whole free allowance for automation, AI and the API this month. Seriously impressive! It resets next month, or keep the momentum going now for just pennies a day.",
)}
</div>
</div>
@@ -9,14 +9,13 @@
* watermarks, compression — are unmetered, no matter where they're triggered
* from. The distinction is the <em>type of work</em> (manual tool vs
* automation / AI / API), not where the click happens, because automation and
* AI also have UI surfaces. The one-time free grant (default 500) applies
* <em>only</em> to the three billable categories — it is a lifetime allowance,
* not a monthly one, and a team keeps any unused portion after subscribing.
* AI also have UI surfaces. The free grant applies <em>only</em> to the three
* billable categories, and resets each billing period.
*
* <p>Layout: a slim <b>Editor plan</b> card (always-free tools only — no dates,
* no metered split) on top, then a single <b>Processor plan</b> card that
* two-columns the upgrade pitch + benefits (left) against the one-time free
* meter stacked over the call-to-action (right).
* two-columns the upgrade pitch + benefits (left) against the free-grant meter
* stacked over the call-to-action (right).
*
* <p>Two variants:
* - {@link PaygFreeLeader} — the right column's CTA opens the upgrade modal.
@@ -47,8 +46,6 @@ import {
type FreeSnapshot,
} from "@app/components/shared/config/configSections/usageMeters";
// ─── Editor plan card (always-free tools only) ────────────────────────────
interface EditorPlanCardProps {
/** Role pill text on the right. */
pill: string;
@@ -58,8 +55,8 @@ interface EditorPlanCardProps {
/**
* The top card: the free Editor plan. Manual tools only, no billing window —
* the one-time grant lives in the Processor card below, so there's no period
* to show here.
* the metered grant lives in the Processor card below, so there's no period to
* show here.
*/
function EditorPlanCard({ pill, leader }: EditorPlanCardProps) {
const { t } = useTranslation();
@@ -93,8 +90,6 @@ function EditorPlanCard({ pill, leader }: EditorPlanCardProps) {
);
}
// ─── Processor plan card (two-column: pitch + benefits | meter + CTA) ──────
interface ProcessorCardProps {
snap: FreeSnapshot;
/** Leaders get the live CTA; members get the ask-owner note. */
@@ -207,8 +202,6 @@ function ProcessorCard({ snap, isLeader, onTurnOn }: ProcessorCardProps) {
);
}
// ─── Free LEADER ──────────────────────────────────────────────────────────
export interface PaygFreeLeaderProps {
/**
* Called when the user finishes the {@link UpgradeModal} checkout flow.
@@ -265,8 +258,6 @@ function PaygFreeLeaderInner({ onUpgraded }: PaygFreeLeaderProps = {}) {
);
}
// ─── Free MEMBER ──────────────────────────────────────────────────────────
function PaygFreeMemberInner() {
useRenderCount("PaygFreeMember");
const { t } = useTranslation();
@@ -69,10 +69,9 @@ interface UpgradeModalProps {
/** ISO 4217 currency code for the cap input. Default USD. */
currency?: "USD" | "EUR" | "GBP";
/**
* The team's one-time free grant in documents — the real {@code
* The team's free grant in documents per billing period — the real {@code
* wallet.freeAllowance}, threaded from the free-leader view so the step copy
* quotes the backend's number instead of a hardcoded one. A lifetime grant,
* not a monthly one.
* quotes the backend's number instead of a hardcoded one.
*/
freeLimit: number;
/**
@@ -17,12 +17,10 @@ import {
import "@app/components/shared/config/configSections/Payg.css";
import "@app/components/shared/config/configSections/PaygFree.css";
// ─── One-time free grant meter ──────────────────────────────────────────────
export interface FreeSnapshot {
/** One-time free documents used so far (grant remaining). */
/** Free documents used so far this period (grant remaining). */
billableUsed: number;
/** The team's one-time free grant size in documents. */
/** The team's free grant size in documents, per billing period. */
billableLimit: number;
}
@@ -64,9 +62,11 @@ export function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) {
pct={pct}
barLabel={t("payg.free.hero.barAria", "Free PDFs remaining")}
figure={remaining.toLocaleString()}
capSuffix={t("payg.free.hero.capSuffix", "of {{limit}} free PDFs left", {
limit: snap.billableLimit.toLocaleString(),
})}
capSuffix={t(
"payg.free.hero.capSuffix",
"of {{limit}} free PDFs left this month",
{ limit: snap.billableLimit.toLocaleString() },
)}
statusLabel={stateLabel}
meta={
<span>
@@ -77,8 +77,6 @@ export function FreeMeterPanel({ snap }: { snap: FreeSnapshot }) {
);
}
// ─── Monthly spend-cap meter ────────────────────────────────────────────────
export interface SpendCapSnapshot {
/** Money spent so far this billing period, in major currency units. */
spent: number;
@@ -106,8 +104,8 @@ export function spendCapSnapshotFromWallet(
}
/**
* Sibling of {@link FreeMeterPanel} for the money cap rather than the one-time
* free grant. Shares the same bar/status styling and the cap-state labels
* Sibling of {@link FreeMeterPanel} for the money cap rather than the free
* grant. Shares the same bar/status styling and the cap-state labels
* ({@code payg.state.*}) used by the Plan hero, so it reads as the same meter.
*/
export function SpendCapMeterPanel({ snap }: { snap: SpendCapSnapshot }) {
@@ -149,8 +147,6 @@ export function SpendCapMeterPanel({ snap }: { snap: SpendCapSnapshot }) {
);
}
// ─── Prepaid bundle capacity meter ──────────────────────────────────────────
export interface PrepaidSnapshot {
/** Prepaid units still available across the team's in-term pools. */
remaining: number;
@@ -13,10 +13,8 @@ function toCredits(
freeRemaining: number,
freeAllowance: number,
): CachedCredits {
// Free teams only. The grant is a lifetime pool that survives subscribing, so
// a paying team would otherwise sit on a permanent "0 of 500" in red while
// nothing is wrong. Plan draws the same line — subscribed teams get the
// spend-vs-cap meter there, and admins get usage in the processor.
// Free teams only: a payer's headline number is spend against cap, and a
// draining free meter beside a live invoice reads as a problem.
if (status === "subscribed") return null;
return { remaining: freeRemaining, total: freeAllowance };
}
@@ -4,7 +4,7 @@
*
* <ul>
* <li>{@code 402 FEATURE_DEGRADED} — an authenticated (JWT/web) team hit a
* billable feature it no longer has: a free team that spent its one-time
* billable feature it no longer has: a free team that spent this period's
* allowance, or a subscribed team over its monthly spending cap. Which
* one is told by the {@code subscribed} field on the body.</li>
* <li>{@code 402 PAYG_LIMIT_REACHED} — same situation reached via an API key
@@ -0,0 +1,95 @@
import { describe, it, expect, vi } from "vitest";
import { render as baseRender } from "@testing-library/react";
import { MantineProvider } from "@mantine/core";
import type { FileId } from "@app/types/file";
import type { StirlingFileStub } from "@app/types/fileContext";
/**
* The grid's items are memoized so a selection click re-renders the cards whose
* selection changed rather than the whole folder. That only holds while every prop
* they take stays stable - one inline object or closure at a call site silently
* undoes it, with no visible symptom until a folder is large. These count renders
* so that regression fails here instead of in someone's 500-file folder.
*/
// @app/ui wraps Mantine, so the provider has to be in the tree.
const render = (ui: Parameters<typeof baseRender>[0]) =>
baseRender(ui, { wrapper: MantineProvider });
// Every card renders this exactly once, so its calls are a per-card render count.
const badgeRenders: { n: number } = { n: 0 };
vi.mock("@app/components/shared/PolicyBadges", () => ({
PolicyBadges: () => {
badgeRenders.n += 1;
return null;
},
}));
const buildStub = (id: string, name: string): StirlingFileStub =>
({
id: id as FileId,
name,
type: "application/pdf",
size: 1_000,
lastModified: 0,
isLeaf: true,
originalFileId: id,
versionNumber: 1,
// Set so useLazyThumbnail short-circuits instead of reading IndexedDB.
thumbnailUrl: "data:image/svg+xml,%3Csvg/%3E",
}) as StirlingFileStub;
describe("FileGrid item memoization", () => {
it("re-renders only the cards whose selection changed", async () => {
const { FileGrid } = await import("@app/components/filesPage/FileGrid");
const { FileContextProvider } = await import("@app/contexts/FileContext");
const files = ["a", "b", "c", "d"].map((id) => buildStub(id, `${id}.pdf`));
const entries = files.map((file) => ({ kind: "file" as const, file }));
const props = {
entries,
viewMode: "grid" as const,
onSelectFile: () => {},
onOpenFolder: () => {},
onOpenFile: () => {},
onMoveFiles: () => {},
onMoveFolder: () => {},
onRenameFolder: () => {},
onDeleteFolder: () => {},
onChangeFolderAppearance: () => {},
onRemoveFiles: () => {},
onPromptMoveFiles: () => {},
};
const view = render(
<FileContextProvider>
<FileGrid {...props} selectedFileIds={new Set<FileId>()} />
</FileContextProvider>,
);
const cards = () =>
view.container.querySelectorAll(".files-page-card:not(.is-folder)");
expect(cards()).toHaveLength(4);
const initialRenders = badgeRenders.n;
expect(initialRenders).toBeGreaterThanOrEqual(4);
// Selecting one file changes isSelected for exactly one card. The rest take
// identical props, so memo should skip them.
view.rerender(
<FileContextProvider>
<FileGrid
{...props}
selectedFileIds={new Set<FileId>(["a" as FileId])}
/>
</FileContextProvider>,
);
expect(cards()).toHaveLength(4);
expect(
view.container.querySelectorAll(".files-page-card.is-selected"),
).toHaveLength(1);
// The point of the exercise: one card changed, so the re-render count moves by
// one card's worth and not four. Unmemoized items redraw the whole folder here.
const rerendered = badgeRenders.n - initialRenders;
const perCard = initialRenders / 4;
expect(rerendered).toBe(perCard);
});
});
@@ -5,6 +5,7 @@ import {
type FilesPageEntry,
} from "@app/components/filesPage/FileGrid";
import { FileContextProvider } from "@app/contexts/FileContext";
import { NewFolderButton } from "@app/components/filesPage/NewFolderButton";
import type { StirlingFileStub } from "@app/types/fileContext";
import type { FileId } from "@app/types/file";
@@ -109,6 +110,17 @@ export const Empty: Story = {
loading: false,
currentTab: "all",
onEmptyUpload: () => {},
onEmptyCreateFolder: () => {},
// The page owns this control, so the story stands one up to keep both CTAs on
// screen here.
emptyNewFolderControl: (
<NewFolderButton
label="New folder"
size="md"
currentFolderId={null}
canAddLocalFolder={false}
onAddLocalFolder={() => {}}
onOpenDialog={() => {}}
/>
),
},
};
File diff suppressed because it is too large Load Diff
@@ -23,7 +23,6 @@ import CloseIcon from "@mui/icons-material/Close";
import SearchIcon from "@mui/icons-material/Search";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import QrCode2Icon from "@mui/icons-material/QrCode2";
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
import GridViewIcon from "@mui/icons-material/GridView";
import ViewListIcon from "@mui/icons-material/ViewList";
import DeleteIcon from "@mui/icons-material/Delete";
@@ -37,6 +36,7 @@ import { FilesToolbarBulkMenu } from "@app/components/filesPage/FilesToolbarBulk
import { FilesToolbarCount } from "@app/components/filesPage/FilesToolbarCount";
import { FilesToolbarFilterMenu } from "@app/components/filesPage/FilesToolbarFilterMenu";
import { FilesToolbarSortMenu } from "@app/components/filesPage/FilesToolbarSortMenu";
import { NewFolderButton } from "@app/components/filesPage/NewFolderButton";
import { stripBasePath } from "@app/constants/app";
import { useAuth } from "@app/auth/UseSession";
@@ -45,6 +45,7 @@ import { useFolders } from "@app/contexts/FolderContext";
import { useFileActions } from "@app/contexts/file/fileHooks";
import { useAllFiles } from "@app/contexts/FileContext";
import { useFileHandler } from "@app/hooks/useFileHandler";
import { useServerFolderBlock } from "@app/hooks/useServerFolderBlock";
import {
useNavigationActions,
useNavigationGuard,
@@ -60,7 +61,7 @@ import { getFileOrigin } from "@app/components/filesPage/fileOrigin";
import { FileId } from "@app/types/file";
import { StirlingFileStub } from "@app/types/fileContext";
import { FolderId, ROOT_FOLDER_ID } from "@app/types/folder";
import { FolderId, ROOT_FOLDER_ID, folderKind } from "@app/types/folder";
import { FileGrid, FilesPageEntry } from "@app/components/filesPage/FileGrid";
import SuperSearch from "@app/components/shared/superSearch/SuperSearch";
@@ -69,6 +70,20 @@ import { FileDetailsPanel } from "@app/components/filesPage/FileDetailsPanel";
import BulkUploadToServerModal from "@app/components/shared/BulkUploadToServerModal";
import MobileUploadModal from "@app/components/shared/MobileUploadModal";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { canPickDirectory } from "@app/services/directoryPicker";
import {
diskFolderId,
isDiskFolderId,
pickFolderColor,
} from "@app/types/folder";
import { useNewFolderFlow } from "@app/hooks/useNewFolderFlow";
import { writeIntoMount } from "@app/services/mountWrites";
import {
canListDirectory,
listDirectory,
readDiskFile,
type DiskFileEntry,
} from "@app/services/localFolderContents";
import { useIsMobile } from "@app/hooks/useIsMobile";
import { MoveToFolderDialog } from "@app/components/filesPage/MoveToFolderDialog";
import { FolderNameDialog } from "@app/components/filesPage/FolderNameDialog";
@@ -201,21 +216,48 @@ export default function FileManagerView() {
);
const setCurrentFolderId = folders.setCurrentFolderId;
const resolveDiskFolder = folders.resolveDiskFolder;
const foldersById = folders.foldersById;
const currentFolderId = folders.currentFolderId;
// Sync the URL into FolderContext.
// Which folder the path last selected. The two effects below keep the path and the
// selection in step, and each uses this to tell its own write from the other's.
const pathSelectedRef = useRef<string | null>(null);
// Path -> selection. Covers arrival, a deep link, and back/forward.
useEffect(() => {
const match = location.pathname.match(/^\/files\/([^/]+)/);
const param = match?.[1] ?? null;
if (param === null) {
pathSelectedRef.current = null;
setCurrentFolderId(ROOT_FOLDER_ID);
} else if (foldersById.has(param as FolderId)) {
return;
}
if (foldersById.has(param as FolderId)) {
pathSelectedRef.current = param;
setCurrentFolderId(param as FolderId);
} else {
return;
}
if (isDiskFolderId(param) && resolveDiskFolder(param as FolderId)) {
// A mount subdirectory deep link: rebuilt from the id, mapped next render.
pathSelectedRef.current = param;
setCurrentFolderId(param as FolderId);
return;
}
// Not known yet is not the same as not real: folders load asynchronously, and a
// mount's subdirectories arrive with the listing that finds them. Wait for the map
// to fill - this re-runs as it does - and only fall back once it cannot.
if (!folders.loading) {
pathSelectedRef.current = null;
setCurrentFolderId(ROOT_FOLDER_ID);
}
}, [location.pathname, foldersById, setCurrentFolderId]);
}, [
location.pathname,
foldersById,
setCurrentFolderId,
resolveDiskFolder,
folders.loading,
]);
// Bounce off any share-related tab when sharing isn't enabled.
useEffect(() => {
@@ -227,14 +269,19 @@ export default function FileManagerView() {
}
}, [sharingEnabled, currentTab, setCurrentTab]);
// Push folder selection into the URL while still on /files.
// Selection -> path, for a folder opened here. Pushed, not replaced: each folder is
// its own history entry, so Back walks up the tree rather than out of the library.
useEffect(() => {
const stripped = stripBasePath(window.location.pathname);
if (!stripped.startsWith("/files")) return;
const target =
currentFolderId === null ? "/files" : `/files/${currentFolderId}`;
const selected = currentFolderId === null ? null : String(currentFolderId);
// The path already says this, being what selected the folder. Writing it again
// overwrites the entry a back or forward just landed on.
if (pathSelectedRef.current === selected) return;
const target = selected === null ? "/files" : `/files/${selected}`;
if (stripped !== target) {
navigate(target, { replace: true });
pathSelectedRef.current = selected;
navigate(target);
}
}, [currentFolderId, navigate]);
@@ -266,7 +313,6 @@ export default function FileManagerView() {
const visibleFolders = useMemo(() => {
// Folders only appear in cloud-rooted tabs.
if (
currentTab === "local" ||
currentTab === "recent" ||
currentTab === "shared" ||
currentTab === "sharedByMe"
@@ -275,6 +321,14 @@ export default function FileManagerView() {
}
const lc = search.toLowerCase();
const matched = folders.folders.filter((f) => {
// The Cloud tab is the server's view: browser folders and mounts aren't on it.
if (currentTab === "cloud" && folderKind(f) !== "server") return false;
// A folder answers to the source filter the way its files would: a server
// folder is cloud, a browser folder and a mount are both local.
if (originFilter !== "all") {
const folderOrigin = folderKind(f) === "server" ? "cloud" : "local";
if (folderOrigin !== originFilter) return false;
}
if (search) {
// Subtree-wide name match; exclude the current folder itself.
return (
@@ -289,17 +343,19 @@ export default function FileManagerView() {
return matched.sort((a, b) =>
a.name.localeCompare(b.name, undefined, { sensitivity: "base" }),
);
}, [folders.folders, currentFolderId, search, currentTab, subtreeFolderIds]);
}, [
folders.folders,
currentFolderId,
search,
currentTab,
subtreeFolderIds,
originFilter,
]);
// Files in current folder, pre-filter. Drives the type-filter dropdown.
const filesInCurrentFolder = useMemo(() => {
// Tab overrides folder navigation for Local/Recent/Shared.
switch (currentTab) {
case "local":
// Local = files with no server copy. folderId is forced null on this
// path (cf. file.ts comment), but we check remoteStorageId too so
// stale local-folder rows from a pre-pivot DB don't slip through.
return allFiles.filter((f) => f.remoteStorageId == null);
case "cloud":
// Cloud bucket; search widens to subtree, else direct-folder match.
return allFiles.filter((f) => {
@@ -426,12 +482,141 @@ export default function FileManagerView() {
[foldersById],
);
const currentFolder = currentFolderId
? folders.foldersById.get(currentFolderId)
: undefined;
const currentLocalDirectory =
currentFolder && folderKind(currentFolder) === "local"
? currentFolder.directory
: undefined;
const { setError: setFolderError, registerDiskSubfolders } = folders;
const [diskEntries, setDiskEntries] = useState<DiskFileEntry[]>([]);
const [diskLoading, setDiskLoading] = useState(false);
// Bumped when this view writes into the directory, so the listing re-reads.
const [diskRefreshTick, setDiskRefreshTick] = useState(0);
useEffect(() => {
if (!currentLocalDirectory || !canListDirectory) {
setDiskEntries([]);
// Leaving a mount mid-listing cancels the in-flight reset, so clear the
// flag here or the skeleton covers every folder for the rest of the session.
setDiskLoading(false);
return;
}
let cancelled = false;
setDiskLoading(true);
listDirectory(currentLocalDirectory)
.then((listed) => {
if (cancelled) return;
setDiskEntries(listed?.files ?? []);
if (currentFolderId !== null) {
registerDiskSubfolders(
currentFolderId,
(listed?.directories ?? []).map((dir) => ({
id: diskFolderId(dir.path),
kind: "local" as const,
name: dir.name,
parentFolderId: currentFolderId,
directory: dir.path,
color: pickFolderColor(dir.name),
createdAt: 0,
updatedAt: 0,
})),
);
}
})
.catch((err) => {
console.warn("[FileManagerView] disk listing failed", err);
if (!cancelled) {
setDiskEntries([]);
setFolderError(
err instanceof Error
? t("filesPage.error.readFolderFailedDetail", {
message: err.message,
defaultValue: `Could not read the folder: ${err.message}`,
})
: t(
"filesPage.error.readFolderFailed",
"Could not read the folder.",
),
);
}
})
.finally(() => {
if (!cancelled) setDiskLoading(false);
});
return () => {
cancelled = true;
};
// The stable setter, not the context: its identity changes on every folder
// mutation, including the setError above, so a failing listing would re-trigger.
}, [
currentLocalDirectory,
currentFolderId,
registerDiskSubfolders,
setFolderError,
diskRefreshTick,
t,
]);
const openDiskFile = useCallback(
async (entry: DiskFileEntry) => {
try {
const file = await readDiskFile(entry);
if (!file) return;
clearFilesPageReturnRoute();
await addFiles([file], { selectFiles: true });
navActions.setWorkbench("viewer");
navigate("/");
} catch (err) {
folders.setError(
err instanceof Error
? t("filesPage.error.openDiskFileFailedDetail", {
name: entry.name,
message: err.message,
defaultValue: `Could not open ${entry.name}: ${err.message}`,
})
: t("filesPage.error.openDiskFileFailed", {
name: entry.name,
defaultValue: `Could not open ${entry.name}.`,
}),
);
}
},
[addFiles, navActions, navigate, folders, t],
);
const entries = useMemo<FilesPageEntry[]>(() => {
// When searching, items may come from anywhere in the subtree, so we
// expose a "parentPath" subtitle whenever the item's parent differs from
// currentFolderId. When no search is active, every item is in the
// current folder by definition and the subtitle is suppressed.
const inSearch = search.length > 0;
// Inside a mount the listing is the directory; storage rows don't apply.
if (currentLocalDirectory) {
const needle = search.toLowerCase();
const compare: Record<
string,
(a: DiskFileEntry, b: DiskFileEntry) => number
> = {
"name-asc": (a, b) => a.name.localeCompare(b.name),
"name-desc": (a, b) => b.name.localeCompare(a.name),
"size-asc": (a, b) => a.sizeBytes - b.sizeBytes,
"size-desc": (a, b) => b.sizeBytes - a.sizeBytes,
"modified-asc": (a, b) => a.lastModified - b.lastModified,
"modified-desc": (a, b) => b.lastModified - a.lastModified,
};
return [
...visibleFolders.map<FilesPageEntry>((folder) => ({
kind: "folder",
folder,
folderFileCount: 0,
})),
...diskEntries
.filter((disk) => !needle || disk.name.toLowerCase().includes(needle))
.sort(compare[filesPage.sortMode] ?? compare["modified-desc"]!)
.map<FilesPageEntry>((disk) => ({ kind: "diskFile", disk })),
];
}
return [
...visibleFolders.map<FilesPageEntry>((folder) => ({
kind: "folder",
@@ -457,6 +642,9 @@ export default function FileManagerView() {
filesPage.fileCountsByFolder,
search,
currentFolderId,
currentLocalDirectory,
diskEntries,
filesPage.sortMode,
pathForFolderId,
]);
@@ -531,28 +719,46 @@ export default function FileManagerView() {
// state - otherwise the file pops up the next time the user navigates
// to /viewer or /tools, which reads as "auto-opened" and surprised
// people every time. The grid will repaint via refresh() below.
// Files uploaded while standing in a folder belong in that folder.
const target =
currentTab === "all" || currentTab === "cloud" ? currentFolderId : null;
const targetFolder = target ? folders.foldersById.get(target) : undefined;
if (targetFolder && folderKind(targetFolder) === "local") {
const { failedCount } = await writeIntoMount(
targetFolder.directory,
files.map((file) => ({ name: file.name, bytes: async () => file })),
);
if (failedCount > 0) {
folders.setError(
t("filesPage.moveIntoMountFailed", {
count: failedCount,
defaultValue:
"{{count}} file(s) could not be written into the folder.",
}),
);
}
setDiskRefreshTick((tick) => tick + 1);
return;
}
// Everywhere else membership is set with the stub rather than by a move that
// could fail after. For a server folder it stays local until the save lands.
const added = await addFiles(files, {
selectFiles: false,
skipWorkspaceDispatch: true,
...(target ? { folderId: target as string } : {}),
});
const fileIds = added.map((f) => f.fileId);
const target = currentFolderId;
// Uploaded files land in Local (folderId stays null).
if (
target !== null &&
fileIds.length > 0 &&
(currentTab === "all" || currentTab === "cloud")
targetFolder &&
folderKind(targetFolder) === "server"
) {
folders.setError(
t(
"filesPage.uploadedToLocal",
"Uploaded files start in Local. Use 'Save to cloud' to put them in a folder.",
),
);
await moveFilesTo(fileIds, target);
}
await refresh();
},
[addFiles, currentFolderId, currentTab, folders, refresh, t],
[addFiles, currentFolderId, currentTab, folders, moveFilesTo, refresh, t],
);
const onFileInputChange = useCallback(
@@ -576,26 +782,40 @@ export default function FileManagerView() {
const proceed = async () => {
clearFilesPageReturnRoute();
// Already in the workspace: nothing to fetch or add, so just go to it. Sending
// it through materialize and add again has no reason to succeed - the bytes
// are already spoken for.
const alreadyOpen = stubs.filter((stub) =>
activeWorkspaceFileIdSet.has(stub.id as string),
);
const toOpen = stubs.filter(
(stub) => !activeWorkspaceFileIdSet.has(stub.id as string),
);
// Server-only stubs have no bytes in IDB; download + ingest first.
const materialized = await materializeServerStubs(stubs, {
const materialized = await materializeServerStubs(toOpen, {
addFiles: fileActions.addFilesWithOptions,
updateStub: fileActions.updateStirlingFileStub,
});
if (materialized.length !== stubs.length) {
if (materialized.length !== toOpen.length) {
// At least one server download failed; refresh so the grid
// reflects any successful ingests and the user can retry.
await refresh();
return;
}
await fileActions.addStirlingFileStubs(materialized, {
selectFiles: false,
});
// Branch on requested stubs so already-active files still activate.
if (materialized.length === 1) {
setActiveFileId(materialized[0].id);
if (materialized.length > 0) {
await fileActions.addStirlingFileStubs(materialized, {
selectFiles: false,
});
}
// Every file the user asked for, whether it arrived now or was already there.
const opened = [...alreadyOpen, ...materialized];
if (opened.length === 1) {
setActiveFileId(opened[0].id);
navActions.setWorkbench("viewer");
} else if (materialized.length > 1) {
} else if (opened.length > 1) {
navActions.setWorkbench("fileEditor");
}
navigate(EDITOR_BASENAME);
@@ -613,6 +833,8 @@ export default function FileManagerView() {
navigate,
requestNavigation,
clearFilesPageReturnRoute,
activeWorkspaceFileIdSet,
refresh,
],
);
@@ -913,19 +1135,15 @@ export default function FileManagerView() {
[selectedFiles, fileMap],
);
// Per-destination availability for the New-folder menu; the reason renders as the
// disabled item's caption.
const serverFolderDisabledReason = useServerFolderBlock() ?? undefined;
const { addLocalFolder } = useNewFolderFlow();
// null = New folder actionable; string = disabled tooltip reason.
const newFolderDisabledReason: string | null = useMemo(() => {
// Guests can't use cloud folders at all - say so before any tab/storage
// hint, since switching tabs wouldn't help them.
if (signInRequiredReason) {
return signInRequiredReason;
}
if (currentTab === "local") {
return t(
"filesPage.localFoldersUnavailable",
"Folders are cloud-only - save a file to the cloud to organise it.",
);
}
// Only All/Cloud render folders, so creating one elsewhere would look inert.
if (
currentTab === "recent" ||
currentTab === "shared" ||
@@ -936,22 +1154,39 @@ export default function FileManagerView() {
"Switch to All or Cloud to create folders.",
);
}
if (!folders.serverReachable) {
return t(
"filesPage.newFolderStorageDisabled",
"Server folder storage isn't enabled. Ask your admin to turn it on.",
);
// A subfolder inherits kind server, so the blockers gate the button rather
// than letting the dialog open and fail at submit.
if (
currentFolder &&
folderKind(currentFolder) === "server" &&
serverFolderDisabledReason
) {
return serverFolderDisabledReason;
}
// The web root creates on the server or not at all.
if (
folders.currentFolderId === null &&
!canPickDirectory &&
serverFolderDisabledReason
) {
return serverFolderDisabledReason;
}
return null;
}, [signInRequiredReason, currentTab, folders.serverReachable, t]);
}, [
currentTab,
currentLocalDirectory,
currentFolder,
folders.currentFolderId,
serverFolderDisabledReason,
t,
]);
return (
<div className="files-page" ref={dropZoneRef}>
<header className="files-page-header">
{/* Breadcrumb only for folder-rooted tabs. */}
{(currentTab === "all" || currentTab === "cloud") && <Breadcrumbs />}
{(currentTab === "local" ||
currentTab === "recent" ||
{(currentTab === "recent" ||
currentTab === "shared" ||
currentTab === "sharedByMe") && (
<div
@@ -962,13 +1197,11 @@ export default function FileManagerView() {
color: "var(--c-text)",
}}
>
{currentTab === "local"
? t("filesPage.tabName.local", "Local")
: currentTab === "recent"
? t("filesPage.tabName.recent", "Recent")
: currentTab === "shared"
? t("filesPage.tabName.shared", "Shared with me")
: t("filesPage.tabName.sharedByMe", "Shared by me")}
{currentTab === "recent"
? t("filesPage.tabName.recent", "Recent")
: currentTab === "shared"
? t("filesPage.tabName.shared", "Shared with me")
: t("filesPage.tabName.sharedByMe", "Shared by me")}
</div>
)}
{(() => {
@@ -977,6 +1210,10 @@ export default function FileManagerView() {
const handleRefresh = async () => {
setRefreshing(true);
try {
// In a mount, refresh means the directory: the listing only re-reads when told.
if (currentLocalDirectory) {
setDiskRefreshTick((tick) => tick + 1);
}
// pullFromServer bumps the folder revision, which the
// FolderProvider's effect reacts to by re-running refresh() -
// no need to await folders.refresh() manually.
@@ -1026,35 +1263,15 @@ export default function FileManagerView() {
<RefreshIcon />
</ActionIcon>
</Tooltip>
{newFolderDisabledReason ? (
<Tooltip
label={newFolderDisabledReason}
withinPortal
multiline
w={220}
>
<span style={{ display: "inline-flex" }}>
<Button
variant="secondary"
size="sm"
leftSection={<CreateNewFolderIcon fontSize="small" />}
disabled
style={{ pointerEvents: "auto" }}
>
{t("filesPage.newFolder", "New folder")}
</Button>
</span>
</Tooltip>
) : (
<Button
variant="secondary"
size="sm"
leftSection={<CreateNewFolderIcon fontSize="small" />}
onClick={() => openNewFolderDialog()}
>
{t("filesPage.newFolder", "New folder")}
</Button>
)}
<NewFolderButton
label={t("filesPage.newFolder", "New folder")}
disabledReason={newFolderDisabledReason}
serverDisabledReason={serverFolderDisabledReason}
currentFolderId={folders.currentFolderId}
canAddLocalFolder={canPickDirectory}
onAddLocalFolder={() => void addLocalFolder()}
onOpenDialog={openNewFolderDialog}
/>
<Button
size="sm"
leftSection={<UploadFileIcon fontSize="small" />}
@@ -1647,10 +1864,11 @@ export default function FileManagerView() {
>
<FileGrid
entries={entries}
loading={loading}
loading={loading || diskLoading}
currentTab={currentTab}
searchActive={search.trim().length > 0}
serverReachable={folders.serverReachable}
onActionError={folders.setError}
selectedFileIds={selectedFileIds}
activeWorkspaceFileIds={activeWorkspaceFileIdSet}
viewMode={viewMode}
@@ -1659,6 +1877,7 @@ export default function FileManagerView() {
onSelectFile={handleSelectFile}
onSetSelection={setSelectedFileIds}
onOpenFolder={handleOpenFolder}
onOpenDiskFile={(entry) => void openDiskFile(entry)}
onOpenFile={handleOpenFile}
onMoveFiles={moveFilesTo}
onMoveFolder={moveFolderTo}
@@ -1692,8 +1911,18 @@ export default function FileManagerView() {
// (disabled tooltips, native file picker, dialog) is
// identical regardless of where the user clicks from.
onEmptyUpload={() => fileInputRef.current?.click()}
onEmptyCreateFolder={() => openNewFolderDialog()}
newFolderDisabledReason={newFolderDisabledReason}
emptyNewFolderControl={
<NewFolderButton
label={t("filesPage.newFolder", "New folder")}
size="md"
disabledReason={newFolderDisabledReason}
serverDisabledReason={serverFolderDisabledReason}
currentFolderId={folders.currentFolderId}
canAddLocalFolder={canPickDirectory}
onAddLocalFolder={() => void addLocalFolder()}
onOpenDialog={openNewFolderDialog}
/>
}
/>
{isDraggingExternal && (
<div className="files-page-drop-overlay" aria-live="polite">
@@ -1704,16 +1933,21 @@ export default function FileManagerView() {
{t("filesPage.dropOverlay", "Drop files to upload")}
</span>
<span className="files-page-drop-overlay-sub">
{/* Behavior contract: per handleNativeUpload above, all
newly-uploaded files start in Local (folderId stays
null) regardless of the current folder view. Saying
"will land in {folder}" was a lie; tell the truth
so the user reaches for Save-to-cloud / Move-to when
they actually want a folder placement. */}
{t(
"filesPage.dropOverlaySub",
"Files start in Local. Use 'Move to' or 'Save to cloud' to organise them into a folder.",
)}
{/* Behavior contract: per handleNativeUpload above, files
dropped inside a folder on the All/Cloud views are
placed into it — a mount takes them onto the disk
itself. Other tabs land drops in Local, so the copy
must match. */}
{(currentTab === "all" || currentTab === "cloud") &&
currentFolderId !== null
? t(
"filesPage.dropOverlaySubFolder",
"They'll be added to this folder.",
)
: t(
"filesPage.dropOverlaySub",
"Files land in Local. Organise them into folders any time.",
)}
</span>
</div>
)}
@@ -1774,7 +2008,14 @@ export default function FileManagerView() {
<MoveToFolderDialog
opened={moveDialog.open}
onClose={closeMoveDialog}
folders={folders.folders}
// Files can go anywhere, but a folder moves only within its own kind and
// never into a mount - a directory's subfolders are the filesystem's.
folders={folders.folders.filter((candidate) => {
if (!moveDialog.folderId) return true;
if (folderKind(candidate) === "local") return false;
const moving = folders.foldersById.get(moveDialog.folderId);
return moving ? folderKind(candidate) === folderKind(moving) : true;
})}
initialFolderId={moveDialog.initial}
disabledFolderId={moveDialog.folderId}
onConfirm={async (target) => {
@@ -11,6 +11,7 @@ interface FileOriginBadgeProps {
origin: FileOrigin;
/** Compact (icon-only) vs full (icon + text). */
compact?: boolean;
tooltip?: string;
}
const styles = {
@@ -44,6 +45,7 @@ const styles = {
export function FileOriginBadge({
origin,
compact = false,
tooltip,
}: FileOriginBadgeProps) {
const { t } = useTranslation();
@@ -88,7 +90,7 @@ export function FileOriginBadge({
);
return (
<Tooltip label={config.tooltip} withinPortal>
<Tooltip label={tooltip ?? config.tooltip} withinPortal>
{badge}
</Tooltip>
);
@@ -319,6 +319,14 @@
gap: 1rem;
}
/* Stands in for the rows outside the rendered window, so the scrollbar reflects the
whole folder. Spans every column: in the grid a spacer sharing a row with cards
would be laid out beside them instead of above. */
.files-page-virtual-pad {
grid-column: 1 / -1;
pointer-events: none;
}
.files-page-list {
display: flex;
flex-direction: column;
@@ -333,6 +341,9 @@
grid-template-columns: 2.25rem minmax(0, 3fr) 1fr 1fr 1fr 2.5rem;
gap: 0.5rem;
align-items: center;
/* Same offscreen skip as .files-page-card. */
content-visibility: auto;
contain-intrinsic-size: auto 3rem;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--c-border-subtle);
cursor: pointer;
@@ -385,6 +396,11 @@
position: relative;
display: flex;
flex-direction: column;
/* Offscreen cards skip layout and paint — a 500-entry folder only pays
for the rows in view. The intrinsic size stands in for unrendered
cards so the scrollbar doesn't jump (auto: measured size once seen). */
content-visibility: auto;
contain-intrinsic-size: auto 13rem;
background: var(--c-surface);
border: 1px solid var(--c-border-subtle);
border-radius: 0.85rem;
@@ -582,8 +598,13 @@
position: absolute;
bottom: 0.4rem;
left: 0.4rem;
/* The overlay itself stays transparent to the card's clicks and drags, but
the badge inside must catch hover or its tooltip can never open. */
pointer-events: none;
}
.files-page-card-origin > * {
pointer-events: auto;
}
/* "Open" badge - file is currently loaded in the active workspace.
Solid pill with white text so it reads against any thumbnail
@@ -1494,3 +1515,14 @@
transform: none;
}
}
/* A disabled destination still has to be read - its caption carries the reason - and
Mantine's disabled colour drops below comfortable contrast in dark mode. Selector
stands on the item's own class because the dropdown renders in a portal. */
.files-page-new-folder-option[data-disabled] {
color: var(--c-text-muted) !important;
opacity: 1;
}
.files-page-new-folder-option[data-disabled] .mantine-Text-root {
color: var(--c-text-subtle) !important;
}
@@ -16,6 +16,7 @@ import { useFolders } from "@app/contexts/FolderContext";
import { FileId } from "@app/types/file";
import {
FolderId,
folderKind,
FolderRecord,
FolderTreeNode,
ROOT_FOLDER_ID,
@@ -71,7 +72,13 @@ export function FolderTreeSidebar({
}: FolderTreeSidebarProps) {
const { t } = useTranslation();
const { tree, currentFolderId, setCurrentFolderId } = useFolders();
const { currentTab, setCurrentTab, moveFolderTo } = useFilesPage();
const {
currentTab,
setCurrentTab,
moveFolderTo,
originFilter,
setOriginFilter,
} = useFilesPage();
return (
<div
@@ -98,8 +105,8 @@ export function FolderTreeSidebar({
}
/>
<LocalRow
isActive={currentTab === "local"}
onSelect={() => setCurrentTab("local")}
isActive={originFilter === "local"}
onSelect={() => setOriginFilter("local")}
/>
{tree.map((node) => (
<TreeNodeRow
@@ -197,10 +204,9 @@ interface LocalRowProps {
}
/**
* Pinned pseudo-folder row that selects the Local tab. Local files don't
* belong to a folder (folders are a cloud concept) so this row is not a
* drop target and has no count badge - the Local view scopes by predicate
* (`remoteStorageId == null`), not by folderId.
* Sets the source filter to local, and nothing else: it narrows whatever view you
* are in rather than being a place of its own. Not a drop target and no count
* badge - a local file has no folder to be counted under.
*/
function LocalRow({ isActive, onSelect }: LocalRowProps) {
const { t } = useTranslation();
@@ -260,6 +266,12 @@ function TreeNodeRow({
}: TreeNodeRowProps) {
const { t } = useTranslation();
const { serverReachable, setError } = useFolders();
// Server folders need the server; a virtual folder is browser-owned and a
// local one is managed by its directory, so its edit items disable with a
// kind-specific hint instead of a wrong "offline" excuse.
const kind = folderKind(node.folder);
const editsDisabled =
kind === "local" || (kind === "server" && !serverReachable);
const { currentTab } = useFilesPage();
const offlineHint = t(
"filesPage.offlineNoFolderEdits",
@@ -433,8 +445,17 @@ function TreeNodeRow({
e.stopPropagation();
onRenameFolder(node.folder);
}}
disabled={!serverReachable}
title={!serverReachable ? offlineHint : undefined}
disabled={editsDisabled}
title={
kind === "local"
? t(
"filesPage.localFolderManagedByDisk",
"This folder is managed by its directory on disk.",
)
: editsDisabled
? offlineHint
: undefined
}
>
{t("filesPage.treeMenu.rename", "Rename")}
</Menu.Item>
@@ -444,24 +465,48 @@ function TreeNodeRow({
e.stopPropagation();
onRequestNewFolder(node.folder.id);
}}
disabled={!serverReachable}
title={!serverReachable ? offlineHint : undefined}
disabled={editsDisabled}
title={
kind === "local"
? t(
"filesPage.localFolderManagedByDisk",
"This folder is managed by its directory on disk.",
)
: editsDisabled
? offlineHint
: undefined
}
>
{t("filesPage.treeMenu.newSubfolder", "New subfolder")}
</Menu.Item>
<Menu.Divider />
<Menu.Item
color="red"
leftSection={<DeleteOutlineIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onDeleteFolder(node.folder);
}}
disabled={!serverReachable}
title={!serverReachable ? offlineHint : undefined}
>
{t("filesPage.treeMenu.delete", "Delete folder")}
</Menu.Item>
{/* Every kind can be removed except a mount's subdirectory, which
is the disk's — the app never deletes directories. A mount
root's removal deletes the record and nothing on disk, so only
the server kind's reachability gate applies. */}
{(kind !== "local" || node.folder.parentFolderId === null) && (
<Menu.Item
color="red"
leftSection={<DeleteOutlineIcon fontSize="small" />}
onClick={(e) => {
e.stopPropagation();
onDeleteFolder(node.folder);
}}
disabled={kind === "server" && !serverReachable}
title={
kind === "server" && !serverReachable
? offlineHint
: undefined
}
>
{kind === "local"
? t(
"filesPage.removeLocalFolder",
"Remove (files stay on disk)",
)
: t("filesPage.treeMenu.delete", "Delete folder")}
</Menu.Item>
)}
</Menu.Dropdown>
</Menu>
</div>
@@ -0,0 +1,126 @@
import type { ReactNode } from "react";
import { Menu, Text, Tooltip } from "@mantine/core";
import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown";
import CloudIcon from "@mui/icons-material/Cloud";
import CreateNewFolderIcon from "@mui/icons-material/CreateNewFolder";
import DriveFolderUploadIcon from "@mui/icons-material/DriveFolderUpload";
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui/Button";
import type { FolderId, FolderKind } from "@app/types/folder";
export interface NewFolderButtonProps {
label: string;
size?: "sm" | "md";
/** Set when a folder cannot be created here at all; also the tooltip. */
disabledReason?: string | null;
/** Set when only the server destination is unavailable; also its tooltip. */
serverDisabledReason?: string | null;
/** A subfolder inherits its parent's kind, so inside one there is no choice. */
currentFolderId: FolderId | null;
/** Whether this build can put a directory on screen to be mounted. */
canAddLocalFolder: boolean;
onAddLocalFolder: () => void;
onOpenDialog: (parentId?: FolderId | null, kind?: FolderKind) => void;
}
/**
* New folder, in the three shapes the destinations allow: blocked with a reason, a
* plain button where only one destination exists, and a menu where two do. Shared by
* the header and the empty state, so one label cannot offer two different things.
*/
export function NewFolderButton({
label,
size = "sm",
disabledReason,
serverDisabledReason,
currentFolderId,
canAddLocalFolder,
onAddLocalFolder,
onOpenDialog,
}: NewFolderButtonProps): ReactNode {
const { t } = useTranslation();
if (disabledReason) {
return (
<Tooltip label={disabledReason} withinPortal multiline w={260}>
{/* Wrapped so the tooltip still opens while the button is disabled. */}
<span style={{ display: "inline-flex" }}>
<Button
variant="secondary"
size={size}
leftSection={<CreateNewFolderIcon fontSize="small" />}
disabled
style={{ pointerEvents: "auto" }}
>
{label}
</Button>
</span>
</Tooltip>
);
}
// Inside a folder the kind is inherited, and on the web the server is the only
// place a folder can go.
if (currentFolderId !== null || !canAddLocalFolder) {
return (
<Button
variant="secondary"
size={size}
leftSection={<CreateNewFolderIcon fontSize="small" />}
onClick={() =>
currentFolderId !== null
? onOpenDialog()
: onOpenDialog(null, "server")
}
>
{label}
</Button>
);
}
return (
<Menu shadow="md" position="bottom-end" withinPortal>
<Menu.Target>
<Button
variant="secondary"
size={size}
leftSection={<CreateNewFolderIcon fontSize="small" />}
rightSection={<ArrowDropDownIcon fontSize="small" />}
>
{label}
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={
<DriveFolderUploadIcon
fontSize="small"
style={{ marginRight: "0.3rem" }}
/>
}
onClick={onAddLocalFolder}
>
{t("filesPage.newFolderMenu.addExisting", "Add local folder")}
</Menu.Item>
<Menu.Item
className="files-page-new-folder-option"
leftSection={<CloudIcon fontSize="small" />}
disabled={Boolean(serverDisabledReason)}
onClick={() => onOpenDialog(null, "server")}
>
{t("filesPage.newFolderMenu.server", "New folder on the server")}
{/* The reason is the caption: a disabled item with no explanation
reads as broken rather than unavailable. */}
<Text size="xs" c="dimmed">
{serverDisabledReason ??
t(
"filesPage.newFolderMenu.serverHint",
"Synced to your account, available wherever you sign in.",
)}
</Text>
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
}
@@ -0,0 +1,117 @@
import { useCallback, useEffect, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
/** Rows above and below the viewport kept mounted, so a fast scroll stays filled. */
const OVERSCAN = 3;
/**
* How many columns the grid is actually laying out. Read off the computed style
* rather than recomputed from a breakpoint, so `auto-fill` stays the one place the
* column count is decided.
*/
function useColumnCount(el: HTMLElement | null): number {
const [columns, setColumns] = useState(1);
useEffect(() => {
if (!el) return;
const read = () => {
const template = getComputedStyle(el).gridTemplateColumns;
const n =
template === "none" ? 1 : template.split(" ").filter(Boolean).length;
setColumns(Math.max(1, n));
};
read();
const ro = new ResizeObserver(read);
ro.observe(el);
return () => ro.disconnect();
}, [el]);
return columns;
}
/** The scrolling ancestor the virtualiser measures against. */
function useScrollParent(el: HTMLElement | null): HTMLElement | null {
const [parent, setParent] = useState<HTMLElement | null>(null);
useEffect(() => {
setParent(el?.closest<HTMLElement>(".files-page-content") ?? null);
}, [el]);
return parent;
}
interface VirtualFileRows {
/** The slice to render, or every index when virtualisation is standing down. */
range: { start: number; end: number };
/** Height to leave above and below the slice, keeping the scrollbar honest. */
padTop: number;
padBottom: number;
columns: number;
/** Ref for the element the rows live in. */
setContainer: (el: HTMLDivElement | null) => void;
}
/**
* Renders a window of a long file list instead of all of it, as a slice plus a
* spacer at each end. Spacers rather than absolute positioning so the grid keeps
* its own `auto-fill` layout and the list its own row flow.
*
* Stands down - every item rendered, no spacers - until there is a scrolling
* ancestor with a measured height. That covers a short list, the first paint
* before layout, and any environment without real geometry.
*/
export function useVirtualFileRows(
itemCount: number,
rowHeightEstimate: number,
isGrid: boolean,
): VirtualFileRows {
const [container, setContainer] = useState<HTMLDivElement | null>(null);
const scrollParent = useScrollParent(container);
const measuredColumns = useColumnCount(isGrid ? container : null);
const columns = isGrid ? measuredColumns : 1;
const rowCount = Math.ceil(itemCount / columns);
const getScrollElement = useCallback(() => scrollParent, [scrollParent]);
const virtualizer = useVirtualizer({
count: rowCount,
getScrollElement,
estimateSize: () => rowHeightEstimate,
overscan: OVERSCAN,
});
const rows = virtualizer.getVirtualItems();
const active = Boolean(scrollParent) && rows.length > 0;
if (!active) {
return {
range: { start: 0, end: itemCount },
padTop: 0,
padBottom: 0,
columns,
setContainer,
};
}
const first = rows[0];
const last = rows[rows.length - 1];
return {
range: {
start: first.index * columns,
end: Math.min((last.index + 1) * columns, itemCount),
},
padTop: first.start,
padBottom: Math.max(0, virtualizer.getTotalSize() - last.end),
columns,
setContainer,
};
}
// Read once. The root font size is a layout read, and this is called on every render
// of a list whose whole point is not doing needless work. A root restyled mid-session
// keeps the first answer, which only shifts an estimate.
let rootFontSizePx = 0;
/** Card and row heights including their gap, matching contain-intrinsic-size. */
export function rowHeightPx(isGrid: boolean): number {
if (rootFontSizePx === 0) {
rootFontSizePx =
parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
}
return isGrid ? 14 * rootFontSizePx : 3 * rootFontSizePx;
}
@@ -267,6 +267,8 @@ function FileContextInner({
skipWorkspaceDispatch?: boolean;
skipUploadTracking?: boolean;
derivedFromTool?: boolean;
/** Folder every added file is born into (see AddFileOptions). */
folderId?: string;
},
): Promise<StirlingFile[]> => {
const stirlingFiles = await addFiles(
@@ -13,8 +13,15 @@ import { useTranslation } from "react-i18next";
import { FileId } from "@app/types/file";
import { StirlingFileStub } from "@app/types/fileContext";
import { FolderId, FolderRecord, ROOT_FOLDER_ID } from "@app/types/folder";
import {
FolderId,
FolderKind,
FolderRecord,
ROOT_FOLDER_ID,
folderKind,
} from "@app/types/folder";
import { fileStorage } from "@app/services/fileStorage";
import { writeIntoMount } from "@app/services/mountWrites";
import { folderSyncService } from "@app/services/folderSyncService";
import { uploadHistoryChain } from "@app/services/serverStorageUpload";
import { reconcileServerFiles } from "@app/services/fileSyncService";
@@ -48,17 +55,13 @@ export type FilesPageOriginFilter =
| "shared-with-me";
/** all|local|cloud|recent|shared filter presets. */
export type FilesPageTab =
| "all"
| "local"
| "cloud"
| "recent"
| "shared"
| "sharedByMe";
export type FilesPageTab = "all" | "cloud" | "recent" | "shared" | "sharedByMe";
export interface FolderNameDialogState {
mode: "new" | "rename" | null;
parentId?: FolderId | null;
/** For a root-level create: the kind the caller chose (menu, not dialog). */
kind?: FolderKind;
folder?: FolderRecord;
}
@@ -102,7 +105,7 @@ interface FilesPageContextValue {
// Dialog state
folderNameDialog: FolderNameDialogState;
openNewFolderDialog: (parentId?: FolderId | null) => void;
openNewFolderDialog: (parentId?: FolderId | null, kind?: FolderKind) => void;
openRenameFolderDialog: (folder: FolderRecord) => void;
closeFolderNameDialog: () => void;
submitFolderName: (name: string) => Promise<void>;
@@ -243,8 +246,11 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
useState<FolderNameDialogState>({ mode: null });
const openNewFolderDialog = useCallback(
(parentId: FolderId | null = folders.currentFolderId) => {
setFolderNameDialog({ mode: "new", parentId });
(
parentId: FolderId | null = folders.currentFolderId,
kind?: FolderKind,
) => {
setFolderNameDialog({ mode: "new", parentId, kind });
},
[folders.currentFolderId],
);
@@ -260,9 +266,11 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
const submitFolderName = useCallback(
async (name: string) => {
if (folderNameDialog.mode === "new") {
// Chosen before the dialog opened, and only used at the root.
await folders.createFolder(
name,
folderNameDialog.parentId ?? folders.currentFolderId,
folderNameDialog.kind,
);
} else if (
folderNameDialog.mode === "rename" &&
@@ -297,13 +305,89 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
const moveFilesTo = useCallback(
async (fileIds: FileId[], folderId: FolderId | null) => {
if (fileIds.length === 0) return;
const stubs = fileIds
.map((id) => fileMap.get(id))
.filter((s): s is StirlingFileStub => Boolean(s));
// fileMap is a render-time snapshot, so a file created moments ago is not in
// it yet. Storage is the truth, and falling back keeps it in the move.
const fetched = await Promise.all(
fileIds.map(
(id) => fileMap.get(id) ?? fileStorage.getStirlingFileStub(id),
),
);
const stubs = fetched.filter((s): s is StirlingFileStub => Boolean(s));
const localOnly = stubs.filter((s) => s.remoteStorageId == null);
// Cloud list is mutated below with newly-promoted local files.
const cloudFiles = stubs.filter((s) => s.remoteStorageId != null);
const targetFolder =
folderId === null ? null : folders.foldersById.get(folderId);
const targetKind = targetFolder ? folderKind(targetFolder) : null;
if (targetKind === "local") {
// In a mount means on the disk: write each file into the directory, then retire
// the app-side copy once the bytes verifiably landed.
const { written, failedCount } = await writeIntoMount(
targetFolder?.directory,
localOnly.map((stub) => ({
name: stub.name,
bytes: () => fileStorage.getStirlingFile(stub.id),
})),
);
const movedIds = localOnly
.filter((_, i) => written[i])
.map((stub) => stub.id);
if (movedIds.length > 0) {
// Superseded versions go too, or their bytes sit in storage unseen.
const orphans = await fileStorage.orphanedAncestorIds(movedIds);
await fileActions.removeFiles([...movedIds, ...orphans], true);
}
// One error slot, two possible failures: report both.
const notices: string[] = [];
if (failedCount > 0) {
notices.push(
t("filesPage.moveIntoMountFailed", {
count: failedCount,
defaultValue:
"{{count}} file(s) could not be written into the folder.",
}),
);
}
if (cloudFiles.length > 0) {
notices.push(
t("filesPage.moveIntoMountCloudSkipped", {
count: cloudFiles.length,
defaultValue:
"{{count}} server file(s) stayed in your files. They live on the server, not on this disk.",
}),
);
}
if (notices.length > 0) {
folders.setError(notices.join(" "));
}
await refresh();
return;
}
if (targetKind === "virtual") {
// A browser-owned folder cannot hold server files: the next sync would snap
// them back, so they are left where they are and reported.
if (cloudFiles.length > 0) {
folders.setError(
t(
"filesPage.moveIntoVirtualCloudSkipped",
"{{count}} server file(s) were left in place. Server files can't live in browser-only folders.",
{ count: cloudFiles.length },
),
);
}
if (localOnly.length > 0) {
await indexedDB.moveFilesToFolder(
localOnly.map((s) => s.id),
folderId,
);
}
await refresh();
return;
}
if (folderId !== null && localOnly.length > 0) {
// Per-file uploadHistoryChain so each gets its own remoteStorageId.
try {
@@ -379,7 +463,17 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
}
}
// Local files moving to ROOT need no cloud write.
// Local files moving to the root DO need a write when they are leaving a folder —
// their membership is a browser-side folderId that nothing above has touched (the
// upload branch only runs for a non-null target).
if (folderId === null && localOnly.length > 0) {
const leaving = localOnly
.filter((s) => (s.folderId ?? null) !== null)
.map((s) => s.id);
if (leaving.length > 0) {
await indexedDB.moveFilesToFolder(leaving, null);
}
}
await refresh();
},
[indexedDB, refresh, fileMap, folders, t, fileActions],
@@ -397,6 +491,22 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
);
return;
}
// A subtree is one kind throughout (each kind has its own system of
// record), so a cross-kind drop is refused here as a message rather
// than surfacing as a thrown error from the context.
if (newParentId !== null) {
const source = folders.foldersById.get(folderId);
const target = folders.foldersById.get(newParentId);
if (source && target && folderKind(source) !== folderKind(target)) {
folders.setError(
t(
"filesPage.moveAcrossKindsBlocked",
"These folders live in different places, so one can't go inside the other.",
),
);
return;
}
}
await folders.moveFolder(folderId, newParentId);
},
[folders, t],
@@ -554,10 +664,29 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
const promptDeleteFolder = useCallback(
(folder: FolderRecord) => {
if (folderKind(folder) === "local") {
// Removing a mount destroys nothing — the record goes, the directory and every
// file in it stay — so there is nothing to warn about and the delete dialog's
// "what about the files?" question would be a scary lie.
void folders.deleteFolder(folder.id).catch((err) => {
folders.setError(
err instanceof Error
? t("filesPage.error.removeFolderFailedDetail", {
message: err.message,
defaultValue: `Could not remove folder: ${err.message}`,
})
: t(
"filesPage.error.removeFolderFailed",
"Could not remove folder.",
),
);
});
return;
}
const fileCount = filesInSubtree(folder.id).length;
setDeleteFolderDialog({ folder, fileCount });
},
[filesInSubtree],
[filesInSubtree, folders, t],
);
const deleteFolder = useCallback(
@@ -93,6 +93,26 @@ vi.mock("@app/services/folderStorage", () => ({
},
}));
// The virtual store is exercised by its own suite (virtualFolderStorage.test);
// here it only needs to exist and be empty so the merged load resolves.
vi.mock("@app/services/virtualFolderStorage", () => ({
virtualFolderStorage: {
getAllFolders: vi.fn(() => Promise.resolve([])),
createFolder: vi.fn(),
updateFolder: vi.fn(),
moveFolder: vi.fn(),
deleteFolder: vi.fn(() => Promise.resolve([])),
},
}));
vi.mock("@app/services/localFolderStorage", () => ({
localFolderStorage: {
getAllFolders: vi.fn(() => Promise.resolve([])),
mountDirectory: vi.fn(),
removeFolder: vi.fn(() => Promise.resolve()),
},
}));
vi.mock("@app/contexts/IndexedDBContext", () => ({
useIndexedDB: () => ({
clearFolderForFiles: vi.fn().mockResolvedValue(undefined),
@@ -26,16 +26,25 @@ import React, {
} from "react";
import { folderStorage } from "@app/services/folderStorage";
import { virtualFolderStorage } from "@app/services/virtualFolderStorage";
import { localFolderStorage } from "@app/services/localFolderStorage";
import { folderSyncService } from "@app/services/folderSyncService";
import {
FolderBreadcrumbEntry,
FolderId,
FolderKind,
FolderRecord,
FolderTreeNode,
ROOT_FOLDER_ID,
createFolderId,
diskFolderId,
diskFolderPath,
folderKind,
isDiskFolderId,
pickFolderColor,
} from "@app/types/folder";
import { directoryKey } from "@app/services/localFolderStorage";
import { makeDiskDirectory } from "@app/services/localFolderContents";
import { useIndexedDB } from "@app/contexts/IndexedDBContext";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useAuth } from "@app/auth/UseSession";
@@ -88,9 +97,11 @@ interface FolderContextValue {
ok: boolean;
reason?: "endpoint-missing" | "network" | "server" | "client";
}>;
/** Create a folder. A child takes its parent's kind; only a root create chooses. */
createFolder: (
name: string,
parentFolderId?: FolderId | null,
kind?: FolderKind,
) => Promise<FolderRecord>;
renameFolder: (id: FolderId, name: string) => Promise<FolderRecord | null>;
moveFolder: (
@@ -102,6 +113,14 @@ interface FolderContextValue {
appearance: { color?: string; icon?: string | null },
) => Promise<FolderRecord | null>;
deleteFolder: (id: FolderId) => Promise<FolderId[]>;
/** Idempotent per directory: mounting one already mounted returns its record. */
mountLocalFolder: (directory: string, name: string) => Promise<FolderRecord>;
registerDiskSubfolders: (parentId: FolderId, records: FolderRecord[]) => void;
/**
* Rebuild the records behind a disk-subfolder id, for a link arriving before any
* listing ran. True when it sits under a known mount and is now registered.
*/
resolveDiskFolder: (id: FolderId) => boolean;
getChildFolderIds: (parentId: FolderId | null) => FolderId[];
isDescendant: (candidateId: FolderId, ancestorId: FolderId | null) => boolean;
@@ -146,6 +165,25 @@ function buildTree(folders: FolderRecord[]): FolderTreeNode[] {
return build(ROOT_FOLDER_ID, 0);
}
/** The record a subdirectory of a mount presents as: kind local, path as id. */
function diskSubfolderRecord(
path: string,
name: string,
parentFolderId: FolderId,
): FolderRecord {
const now = Date.now();
return {
id: diskFolderId(path),
kind: "local",
name,
parentFolderId,
directory: path,
color: pickFolderColor(name),
createdAt: now,
updatedAt: now,
};
}
/** Convert a server-side error to a banner-ready user message. */
function formatServerError(err: unknown): string {
if (err && typeof err === "object" && "response" in err) {
@@ -241,7 +279,26 @@ function shouldStrandedReset(
}
export function FolderProvider({ children }: FolderProviderProps) {
const [folders, setFolders] = useState<FolderRecord[]>([]);
const [storedFolders, setFolders] = useState<FolderRecord[]>([]);
// Never persisted: a directory is its own record, so a listing rebuilds these.
const [diskSubfolders, setDiskSubfolders] = useState<
Map<FolderId, FolderRecord[]>
>(() => new Map());
const folders = useMemo(() => {
const known = new Set(storedFolders.map((f) => f.id));
const synthesized: FolderRecord[] = [];
for (const records of diskSubfolders.values()) {
for (const record of records) {
if (!known.has(record.id)) {
known.add(record.id);
synthesized.push(record);
}
}
}
return synthesized.length
? [...storedFolders, ...synthesized]
: storedFolders;
}, [storedFolders, diskSubfolders]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Start `false` so folder-mutation buttons are disabled until the first
@@ -269,9 +326,14 @@ export function FolderProvider({ children }: FolderProviderProps) {
const refresh = useCallback(async () => {
setLoading(true);
try {
const all = await folderStorage.getAllFolders();
// Three systems of record behind one list; kind says which rules a row follows.
const [server, virtual, local] = await Promise.all([
folderStorage.getAllFolders(),
virtualFolderStorage.getAllFolders(),
localFolderStorage.getAllFolders(),
]);
if (!mountedRef.current) return;
setFolders(all);
setFolders([...server, ...virtual, ...local]);
} catch (err) {
console.error("[FolderContext] cache read failed", err);
if (mountedRef.current) {
@@ -342,7 +404,11 @@ export function FolderProvider({ children }: FolderProviderProps) {
console.warn("[FolderContext] cache replace failed", cacheErr);
}
if (mountedRef.current) {
setFolders(remote);
// Server-wins is for server rows: the other kinds have no server copy.
setFolders((prev) => [
...remote,
...prev.filter((f) => folderKind(f) !== "server"),
]);
setServerReachable(true);
setError(null);
}
@@ -519,11 +585,65 @@ export function FolderProvider({ children }: FolderProviderProps) {
[bumpFolderRevision, folders, handleStaleFolder],
);
/** The kind of an existing folder, or throw — mutations must never guess. */
const requireKind = useCallback(
(id: FolderId): FolderKind => {
const folder = foldersById.get(id);
if (!folder) throw new Error(`Unknown folder: ${id}`);
return folderKind(folder);
},
[foldersById],
);
const createFolder = useCallback(
async (
name: string,
parentFolderId: FolderId | null = currentFolderId,
kind?: FolderKind,
): Promise<FolderRecord> => {
// A child's kind is its parent's: one subtree, one system of record.
const effectiveKind: FolderKind =
parentFolderId !== null
? requireKind(parentFolderId)
: (kind ?? "server");
if (effectiveKind === "local") {
// A mount's subfolder is a directory: make it on disk, present it as a
// listing would. Mount roots come from the picker, never here.
const parent = parentFolderId ? foldersById.get(parentFolderId) : null;
if (!parent?.directory) {
throw new Error("Cannot create a folder outside a mounted directory");
}
const path = await makeDiskDirectory(parent.directory, name);
if (path === null) {
throw new Error("This build cannot create folders on disk");
}
const record = diskSubfolderRecord(path, name, parent.id);
if (mountedRef.current) {
setDiskSubfolders((prev) => {
const next = new Map(prev);
const siblings = (next.get(parent.id) ?? []).filter(
(f) => f.id !== record.id,
);
next.set(parent.id, [...siblings, record]);
return next;
});
setError(null);
}
bumpFolderRevision();
return record;
}
if (effectiveKind === "virtual") {
const record = await virtualFolderStorage.createFolder(
name,
parentFolderId,
);
if (mountedRef.current) {
setFolders((prev) => [...prev, record]);
setError(null);
}
bumpFolderRevision();
return record;
}
const color = pickFolderColor(name);
// Client-side id makes server idempotency check safe on retry.
const id = createFolderId();
@@ -549,11 +669,41 @@ export function FolderProvider({ children }: FolderProviderProps) {
}
return result;
},
[currentFolderId, runFolderMutation],
[
currentFolderId,
requireKind,
storageBackedByServer,
bumpFolderRevision,
runFolderMutation,
],
);
/** Apply a mutated non-server record to state; the store already has it. */
const applyOwnedRecord = useCallback(
(record: FolderRecord | null): FolderRecord | null => {
if (record !== null && mountedRef.current) {
setFolders((prev) =>
prev.map((f) => (f.id === record.id ? record : f)),
);
setError(null);
}
bumpFolderRevision();
return record;
},
[bumpFolderRevision],
);
const renameFolder = useCallback(
async (id: FolderId, name: string) => {
const kind = requireKind(id);
if (kind === "local") {
throw new Error("A local folder takes its name from its directory");
}
if (kind === "virtual") {
return applyOwnedRecord(
await virtualFolderStorage.updateFolder(id, { name }),
);
}
return runFolderMutation(
() => folderSyncService.update(id, { name }),
async (record) => {
@@ -565,11 +715,23 @@ export function FolderProvider({ children }: FolderProviderProps) {
id,
);
},
[runFolderMutation],
[applyOwnedRecord, requireKind, runFolderMutation],
);
const moveFolder = useCallback(
async (id: FolderId, newParentId: FolderId | null) => {
const kind = requireKind(id);
if (newParentId !== null && requireKind(newParentId) !== kind) {
throw new Error("Folders can only move within their own kind");
}
if (kind === "local") {
throw new Error("A local folder sits where its directory sits");
}
if (kind === "virtual") {
return applyOwnedRecord(
await virtualFolderStorage.moveFolder(id, newParentId),
);
}
return runFolderMutation(
() =>
folderSyncService.update(id, {
@@ -585,7 +747,7 @@ export function FolderProvider({ children }: FolderProviderProps) {
id,
);
},
[runFolderMutation],
[applyOwnedRecord, requireKind, runFolderMutation],
);
const updateFolderAppearance = useCallback(
@@ -593,6 +755,23 @@ export function FolderProvider({ children }: FolderProviderProps) {
id: FolderId,
appearance: { color?: string; icon?: string | null },
) => {
const kind = requireKind(id);
if (kind === "local") {
throw new Error("Local folders cannot be recoloured yet");
}
if (kind === "virtual") {
// Only the fields the picker sent: it sends one key per interaction, and the
// store's spread persists an explicit undefined, so passing both would erase
// the one the user did not touch. icon: null clears the icon, deliberately.
const updates: { color?: string; icon?: string } = {};
if (appearance.color !== undefined) updates.color = appearance.color;
if (appearance.icon !== undefined) {
updates.icon = appearance.icon ?? undefined;
}
return applyOwnedRecord(
await virtualFolderStorage.updateFolder(id, updates),
);
}
return runFolderMutation(
() =>
folderSyncService.update(id, {
@@ -608,11 +787,50 @@ export function FolderProvider({ children }: FolderProviderProps) {
id,
);
},
[runFolderMutation],
[applyOwnedRecord, requireKind, runFolderMutation],
);
const deleteFolder = useCallback(
async (id: FolderId): Promise<FolderId[]> => {
const kind = requireKind(id);
if (kind === "local") {
if (isDiskFolderId(id)) {
throw new Error(
"Subfolders of a mounted directory are removed on disk",
);
}
// Removes the record and nothing else; the directory is the user's.
await localFolderStorage.removeFolder(id);
if (mountedRef.current) {
setError(null);
setFolders((prev) => prev.filter((f) => f.id !== id));
if (currentFolderId === id) {
setCurrentFolderId(ROOT_FOLDER_ID);
}
}
bumpFolderRevision();
return [id];
}
if (kind === "virtual") {
// Same shape as the server path: subtree delete, strand-reset, detach files.
const removed = await virtualFolderStorage.deleteFolder(id);
const removedSet = new Set(removed);
if (mountedRef.current) {
setError(null);
setFolders((prev) => prev.filter((f) => !removedSet.has(f.id)));
if (
currentFolderId &&
shouldStrandedReset(currentFolderId, removedSet, folders)
) {
setCurrentFolderId(ROOT_FOLDER_ID);
}
}
bumpFolderRevision();
await clearFolderForFiles(removed).catch((e) =>
console.warn("[FolderContext] virtual folder file cleanup", e),
);
return removed;
}
// Custom path (not runFolderMutation) because we have two best-effort
// cleanups to coordinate, and need to reset currentFolderId BEFORE the
// cleanups so the user isn't stranded inside a tombstone if the cache
@@ -680,9 +898,94 @@ export function FolderProvider({ children }: FolderProviderProps) {
currentFolderId,
folders,
handleStaleFolder,
requireKind,
],
);
const mountLocalFolder = useCallback(
async (directory: string, name: string): Promise<FolderRecord> => {
const record = await localFolderStorage.mountDirectory(directory, name);
if (mountedRef.current) {
setError(null);
// Idempotent mount can hand back a record that's already listed.
setFolders((prev) =>
prev.some((f) => f.id === record.id) ? prev : [...prev, record],
);
}
bumpFolderRevision();
return record;
},
[bumpFolderRevision],
);
const registerDiskSubfolders = useCallback(
(parentId: FolderId, records: FolderRecord[]) => {
setDiskSubfolders((prev) => {
const before = prev.get(parentId) ?? [];
const same =
before.length === records.length &&
before.every(
(f, i) => f.id === records[i]?.id && f.name === records[i]?.name,
);
if (same) return prev;
const next = new Map(prev);
next.set(parentId, records);
return next;
});
},
[],
);
const resolveDiskFolder = useCallback(
(id: FolderId): boolean => {
const path = diskFolderPath(id);
if (path === null) return false;
const pathKey = directoryKey(path);
// The deepest mount containing the path: nested mounts give a shorter chain.
let mount: FolderRecord | null = null;
let mountKeyLength = -1;
for (const folder of storedFolders) {
if (folderKind(folder) !== "local" || !folder.directory) continue;
const key = directoryKey(folder.directory);
const prefix = key.endsWith("/") ? key : `${key}/`;
if (pathKey.startsWith(prefix) && key.length > mountKeyLength) {
mount = folder;
mountKeyLength = key.length;
}
}
if (!mount?.directory) return false;
// Rebuild every level between the mount and the path, each as a child
// of the one above, so breadcrumbs and the tree have the whole chain.
const sep = path.includes("\\") ? "\\" : "/";
const mountDir = mount.directory.replace(/[\\/]+$/, "");
const rest = path
.slice(mountDir.length)
.split(/[\\/]+/)
.filter(Boolean);
const additions: Array<[FolderId, FolderRecord]> = [];
let parentId: FolderId = mount.id;
let current = mountDir;
for (const segment of rest) {
current = `${current}${sep}${segment}`;
const record = diskSubfolderRecord(current, segment, parentId);
additions.push([parentId, record]);
parentId = record.id;
}
setDiskSubfolders((prev) => {
const next = new Map(prev);
for (const [parent, record] of additions) {
const siblings = next.get(parent) ?? [];
if (!siblings.some((f) => f.id === record.id)) {
next.set(parent, [...siblings, record]);
}
}
return next;
});
return true;
},
[storedFolders],
);
const value = useMemo<FolderContextValue>(
() => ({
folders,
@@ -698,6 +1001,9 @@ export function FolderProvider({ children }: FolderProviderProps) {
refresh,
pullFromServer,
createFolder,
mountLocalFolder,
registerDiskSubfolders,
resolveDiskFolder,
renameFolder,
moveFolder,
updateFolderAppearance,
@@ -717,6 +1023,9 @@ export function FolderProvider({ children }: FolderProviderProps) {
refresh,
pullFromServer,
createFolder,
mountLocalFolder,
registerDiskSubfolders,
resolveDiskFolder,
renameFolder,
moveFolder,
updateFolderAppearance,
@@ -273,6 +273,12 @@ interface AddFileOptions {
/** When true, marks every added stub as derivedFromTool so the policy
* auto-run skips it — used for policy outputs imported via addFiles. */
derivedFromTool?: boolean;
/**
* The folder every added file is born into — membership set at creation, atomically
* with the stub, instead of a separate move that can fail after the file already
* landed somewhere else.
*/
folderId?: string;
}
/**
@@ -444,6 +450,9 @@ export async function addFiles(
// Create new filestub with minimal metadata; hydrate thumbnails/processedFile asynchronously
const fileStub = createNewStirlingFileStub(file, fileId);
if (options.derivedFromTool) fileStub.derivedFromTool = true;
if (options.folderId) {
fileStub.folderId = options.folderId as StirlingFileStub["folderId"];
}
// Early encryption detection for PDFs — set the flag before dispatch so the
// viewer gate and modal queue pick it up immediately instead of after hydration
@@ -17,6 +17,8 @@ export const useFileHandler = () => {
autoUnzip?: boolean;
/** Skip the upload metric - the file isn't new to the system (e.g. a copy). */
skipUploadTracking?: boolean;
/** Folder every added file is born into (see AddFileOptions). */
folderId?: string;
} = {},
): Promise<StirlingFile[]> => {
// Merge default options with passed options - passed options take precedence
@@ -3,6 +3,7 @@ import type { FileId } from "@app/types/file";
import { useFileManagement } from "@app/contexts/FileContext";
import { useIndexedDB } from "@app/contexts/IndexedDBContext";
import { generateThumbnailForFile } from "@app/utils/thumbnailUtils";
import { readDiskFile } from "@app/services/localFolderContents";
const THUMBNAIL_SIZE_LIMIT = 100 * 1024 * 1024; // 100MB
@@ -15,7 +16,7 @@ const LAZY_THUMB_CONCURRENCY = 2;
let activeLazyThumbs = 0;
const lazyThumbQueue: Array<() => Promise<void>> = [];
function scheduleLazyThumb(task: () => Promise<void>): void {
export function scheduleLazyThumb(task: () => Promise<void>): void {
lazyThumbQueue.push(task);
drainLazyThumbQueue();
}
@@ -31,6 +32,32 @@ function drainLazyThumbQueue(): void {
});
}
// Stub updates go through the file context, and each one re-renders every
// consumer of the file list. A big folder filling in generates hundreds of
// thumbnails over minutes; flushing them in windows turns that into a handful
// of re-renders (React batches same-tick updates into one). The card itself
// paints immediately from its local state — only the shared stub waits.
const STUB_THUMB_FLUSH_MS = 500;
const pendingStubThumbs = new Map<
FileId,
{ thumbnail: string; apply: (id: FileId, thumbnail: string) => void }
>();
let stubThumbFlushTimer: ReturnType<typeof setTimeout> | null = null;
function queueStubThumbUpdate(
fileId: FileId,
thumbnail: string,
apply: (id: FileId, thumbnail: string) => void,
): void {
pendingStubThumbs.set(fileId, { thumbnail, apply });
stubThumbFlushTimer ??= setTimeout(() => {
stubThumbFlushTimer = null;
const batch = Array.from(pendingStubThumbs);
pendingStubThumbs.clear();
for (const [id, entry] of batch) entry.apply(id, entry.thumbnail);
}, STUB_THUMB_FLUSH_MS);
}
/**
* Show the stub's thumbnail if present; otherwise pull bytes from IndexedDB,
* generate one, persist it, and update the stub. Server-only files with no
@@ -67,7 +94,9 @@ export function useLazyThumbnail(
if (cancelled || !thumbnail) return;
setThumb(thumbnail);
void indexedDB.updateThumbnail(fileId, thumbnail);
updateStirlingFileStub(fileId, { thumbnailUrl: thumbnail });
queueStubThumbUpdate(fileId, thumbnail, (id, url) =>
updateStirlingFileStub(id, { thumbnailUrl: url }),
);
} catch {
// non-critical
}
@@ -80,3 +109,92 @@ export function useLazyThumbnail(
return thumb;
}
// Keyed by path + mtime + size: an unchanged file never renders twice, an edited one does.
const diskThumbCache = new Map<string, string>();
// Bounded by bytes, not entries: image thumbnails are data URLs that track the
// source, so 300 photos would pin gigabytes of strings for the process lifetime.
const DISK_THUMB_CACHE_MAX_BYTES = 48 * 1024 * 1024;
let diskThumbCacheBytes = 0;
function cacheDiskThumb(key: string, url: string): void {
const prior = diskThumbCache.get(key);
if (prior !== undefined) diskThumbCacheBytes -= prior.length;
while (
diskThumbCacheBytes + url.length > DISK_THUMB_CACHE_MAX_BYTES &&
diskThumbCache.size > 0
) {
// Insertion order makes this FIFO; an evicted thumbnail re-renders on revisit.
const oldest = diskThumbCache.keys().next().value!;
diskThumbCacheBytes -= diskThumbCache.get(oldest)!.length;
diskThumbCache.delete(oldest);
}
diskThumbCache.set(key, url);
diskThumbCacheBytes += url.length;
}
// Reading the bytes is the expensive step, so only for types the generator renders.
const THUMBABLE_EXTENSIONS = new Set([
"pdf",
"png",
"jpg",
"jpeg",
"gif",
"webp",
"bmp",
"svg",
]);
function canEverThumbnail(name: string): boolean {
const ext = name.includes(".") ? name.split(".").pop()!.toLowerCase() : "";
return THUMBABLE_EXTENSIONS.has(ext);
}
/**
* Thumbnail for a disk-listed file, through the same generator and the same concurrency
* gate as stored files — a mounted folder's rows fill in progressively alongside
* everything else instead of stampeding the disk.
*/
export function useDiskThumbnail(entry: {
path: string;
name: string;
sizeBytes: number;
lastModified: number;
}): string | undefined {
const key = `${entry.path}|${entry.lastModified}|${entry.sizeBytes}`;
const [thumb, setThumb] = useState<string | undefined>(() => {
const hit = diskThumbCache.get(key);
return hit === "" ? undefined : hit;
});
useEffect(() => {
const cached = diskThumbCache.get(key);
if (cached !== undefined) {
setThumb(cached === "" ? undefined : cached);
return;
}
if (entry.sizeBytes >= THUMBNAIL_SIZE_LIMIT) return;
if (!canEverThumbnail(entry.name)) return;
let cancelled = false;
scheduleLazyThumb(async () => {
if (cancelled || diskThumbCache.has(key)) return;
try {
const file = await readDiskFile(entry);
if (!file || cancelled) return;
const url = await generateThumbnailForFile(file);
// "" is cached too: a failed/oversized render should not retry on
// every re-mount of the same row.
cacheDiskThumb(key, url);
if (!cancelled && url) setThumb(url);
} catch {
cacheDiskThumb(key, "");
}
});
return () => {
cancelled = true;
};
// The key encodes every field of `entry` this effect reads.
}, [key]);
return thumb;
}
@@ -0,0 +1,70 @@
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useFolders } from "@app/contexts/FolderContext";
import { useFilesPage } from "@app/contexts/FilesPageContext";
import { canPickDirectory, pickDirectory } from "@app/services/directoryPicker";
import { useServerFolderBlock } from "@app/hooks/useServerFolderBlock";
/** The folder-creation flows, shared by every surface that offers them so they
* cannot drift apart. */
export function useNewFolderFlow() {
const { t } = useTranslation();
const folders = useFolders();
const { openNewFolderDialog } = useFilesPage();
const navigate = useNavigate();
const serverFolderBlock = useServerFolderBlock();
// No dialog: the picker is the whole interaction and the directory names the folder.
const addLocalFolder = useCallback(async () => {
try {
const picked = await pickDirectory();
if (!picked) return;
const record = await folders.mountLocalFolder(picked.path, picked.name);
// The path owns folder selection: setting state here races the effect that
// re-runs with the old pathname and snaps back to root.
navigate(`/files/${record.id}`);
} catch (err) {
folders.setError(
err instanceof Error
? t("filesPage.error.addFolderFailedDetail", {
message: err.message,
defaultValue: `Could not add the folder: ${err.message}`,
})
: t("filesPage.error.addFolderFailed", "Could not add the folder."),
);
}
}, [folders, navigate, t]);
// Single-click New folder for surfaces with no menu: the picker where the build
// can see the disk, a server folder on the web, and blocked rather than silent
// when the server cannot take one. Inside a folder the kind is inherited.
const createFolderHere = useCallback(() => {
if (folders.currentFolderId !== null) {
openNewFolderDialog(folders.currentFolderId);
return;
}
if (canPickDirectory) {
void addLocalFolder();
return;
}
// Backstop: surfaces disable themselves, so a click here means stale UI.
if (serverFolderBlock === null) {
openNewFolderDialog(null, "server");
}
}, [
addLocalFolder,
folders.currentFolderId,
openNewFolderDialog,
serverFolderBlock,
]);
// Why the single-click surfaces are disabled, or null. Only the web root blocks:
// desktop always has the picker, and subfolders inherit their kind.
const createFolderHereBlockedReason =
folders.currentFolderId === null && !canPickDirectory
? serverFolderBlock
: null;
return { addLocalFolder, createFolderHere, createFolderHereBlockedReason };
}
@@ -0,0 +1,27 @@
import { useTranslation } from "react-i18next";
import { useAuth } from "@app/auth/UseSession";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useFolders } from "@app/contexts/FolderContext";
/** Why a server folder can't be created right now, or null when it can. */
export function useServerFolderBlock(): string | null {
const { t } = useTranslation();
const { isAnonymous } = useAuth();
const { config: appConfig } = useAppConfig();
const folders = useFolders();
if (isAnonymous) {
return t("filesPage.signInRequired", "Sign in to use cloud storage.");
}
// Two different problems, two different next steps: storage off is an
// admin setting; unreachable is a connectivity state that fixes itself.
if (appConfig?.storageEnabled !== true) {
return t(
"filesPage.newFolderStorageDisabled",
"Server folder storage isn't enabled.",
);
}
if (!folders.serverReachable) {
return t("filesPage.syncError.network", "Could not reach the server.");
}
return null;
}
+18 -6
View File
@@ -56,6 +56,9 @@ import {
useFilesPage,
} from "@app/contexts/FilesPageContext";
import { useFolders } from "@app/contexts/FolderContext";
import { folderKind } from "@app/types/folder";
import { useServerFolderBlock } from "@app/hooks/useServerFolderBlock";
import { useNewFolderFlow } from "@app/hooks/useNewFolderFlow";
import { useFileHandler } from "@app/hooks/useFileHandler";
import { FolderTreePanel } from "@app/components/filesPage/FolderTreePanel";
import type { FileSidebarProps } from "@app/components/shared/FileSidebar";
@@ -771,6 +774,8 @@ const MyFilesSidebarOverrides = forwardRef<HTMLDivElement, FileSidebarProps>(
const filesPage = useFilesPage();
const folders = useFolders();
const { addFiles } = useFileHandler();
const { createFolderHere, createFolderHereBlockedReason } =
useNewFolderFlow();
const handleUpload = useCallback(
async (files: File[]) => {
@@ -787,12 +792,19 @@ const MyFilesSidebarOverrides = forwardRef<HTMLDivElement, FileSidebarProps>(
[addFiles, filesPage, folders.currentFolderId],
);
const newFolderDisabledReason = !folders.serverReachable
? t(
"filesPage.newFolderStorageDisabled",
"Server folder storage isn't enabled. Ask your admin to turn it on.",
)
// Kind-aware: only a server folder's subfolder needs the server, and a mounted
// directory takes no subfolders from here at all.
const railCurrentFolder = folders.currentFolderId
? folders.foldersById.get(folders.currentFolderId)
: undefined;
const railCurrentKind = railCurrentFolder
? folderKind(railCurrentFolder)
: null;
const serverFolderBlock = useServerFolderBlock();
const newFolderDisabledReason =
railCurrentKind === "server"
? serverFolderBlock
: createFolderHereBlockedReason;
return (
<FileSidebar
@@ -803,7 +815,7 @@ const MyFilesSidebarOverrides = forwardRef<HTMLDivElement, FileSidebarProps>(
extraAction={{
icon: <CreateNewFolderIcon />,
label: t("filesPage.newFolder", "New folder"),
onClick: () => filesPage.openNewFolderDialog(),
onClick: createFolderHere,
disabled: newFolderDisabledReason !== null,
disabledTooltip: newFolderDisabledReason ?? undefined,
testId: "files-rail-new-folder",
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,15 @@
/** Picking a directory on the machine, as a real filesystem path. */
export interface PickedDirectory {
/** Absolute path, as the platform writes it. */
path: string;
/** The directory's own name — the mounted folder's display name. */
name: string;
}
export const canPickDirectory = false;
/** Ask the user for a directory; null when cancelled (or unsupported). */
export async function pickDirectory(): Promise<PickedDirectory | null> {
return null;
}
@@ -11,6 +11,7 @@ import { alert } from "@app/components/toast";
import { StirlingFileStub, StirlingFile } from "@app/types/fileContext";
import { FileId } from "@app/types/fileContext";
import { FolderId, parseFolderId } from "@app/types/folder";
import { virtualFolderStorage } from "@app/services/virtualFolderStorage";
import {
isZipBundle,
loadShareBundleEntries,
@@ -119,6 +120,15 @@ export async function reconcileServerFiles(
}
let combinedStubs: StirlingFileStub[];
// Virtual folders are browser-owned, so a stub sitting in one must keep its
// membership through the reconcile — the server's folderId (always null for them) is
// not an opinion about it.
const virtualFolderIds = new Set<FolderId>(
await virtualFolderStorage
.getAllFolders()
.then((folders) => folders.map((folder) => folder.id))
.catch(() => []),
);
const localRemoteIds = new Set(
localStubs
.map((s) => s.remoteStorageId)
@@ -202,7 +212,12 @@ export async function reconcileServerFiles(
// Server is authoritative for cloud-stored files. Don't fall back to
// stub.folderId on null - that would resurrect a stale folder pointer
// after the server SET_NULL'd it (e.g. owner deleted the folder).
folderId: safeParseFolderId(serverFile.folderId),
// EXCEPT when the stub sits in a browser-owned (virtual) folder: the
// server has never heard of that folder, so its null says nothing
// about the membership and must not eject the file from it.
folderId: virtualFolderIds.has((stub.folderId ?? "") as FolderId)
? stub.folderId
: safeParseFolderId(serverFile.folderId),
};
});
@@ -470,6 +485,10 @@ export async function materializeServerStubs(
const primary = ingested[ingested.length - 1]!;
const newId = primary.fileId as FileId;
const remoteUpdates = {
// The ingest above made a new local file, which starts in no folder. Without
// carrying membership across, materialising a file to open it moves it to the
// library root - the copy is the file as far as the library is concerned.
folderId: stub.folderId ?? null,
remoteStorageId: stub.remoteStorageId,
remoteStorageUpdatedAt: stub.remoteStorageUpdatedAt,
remoteOwnerUsername: stub.remoteOwnerUsername,
@@ -11,12 +11,24 @@
* are all the server's job now.
*/
import { FolderId, FolderRecord } from "@app/types/folder";
import { FolderId, FolderRecord, folderKind } from "@app/types/folder";
import {
indexedDBManager,
DATABASE_CONFIGS,
} from "@app/services/indexedDBManager";
/**
* This cache is wiped and rewritten from the server's response on every sync, so a
* non-server folder stored here would silently vanish on the next pull.
*/
function requireServerFolder(folder: FolderRecord): void {
if (folderKind(folder) !== "server") {
throw new Error(
`folderStorage caches server folders only; got kind "${folderKind(folder)}" for ${folder.id}`,
);
}
}
class FolderStorageService {
private readonly dbConfig = DATABASE_CONFIGS.FILES;
private readonly storeName = "folders";
@@ -43,6 +55,7 @@ class FolderStorageService {
reject(transaction.error ?? new Error("folder cache replace aborted"));
store.clear();
for (const folder of folders) {
requireServerFolder(folder);
store.put(folder);
}
});
@@ -50,6 +63,7 @@ class FolderStorageService {
/** Insert or overwrite a single folder in the cache. */
async upsertFolder(folder: FolderRecord): Promise<void> {
requireServerFolder(folder);
const db = await this.getDatabase();
await new Promise<void>((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readwrite");
@@ -51,6 +51,9 @@ function toFolderRecord(dto: ServerFolder): FolderRecord {
dto.parentFolderId === null ? null : parseFolderId(dto.parentFolderId);
return {
id,
// Everything that comes off this wire is a server folder by definition;
// virtual and local folders never round-trip through the server at all.
kind: "server",
name: dto.name,
parentFolderId,
color: dto.color ?? undefined,
@@ -100,8 +100,11 @@ export async function handleHttpError(error: unknown): Promise<boolean> {
pathname.includes("/auth/") ||
pathname.includes("/invite/");
const isPublicMobilePage =
pathname.includes("/mobile-scanner") || pathname.includes("/mobile-sign");
// If not on auth page, redirect to login with expired session message
if (!isAuthPage && !skipAuthRedirect) {
if (!isAuthPage && !isPublicMobilePage && !skipAuthRedirect) {
if (loginRedirectRecentlyFired()) {
console.warn(
"[httpErrorHandler] 401 redirect already fired moments ago — suppressing repeat to avoid a login loop:",
@@ -399,4 +399,71 @@ describe("IndexedDB migration (FILES store)", () => {
TARGET_VERSION,
);
});
test("a v10 profile missing local_folders upgrades to v11 with the full schema", async () => {
// v10 briefly existed with only one of the two browser-folder stores.
await new Promise<void>((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 10);
req.onupgradeneeded = () => {
const db = req.result;
db.createObjectStore("files", { keyPath: "id" });
db.createObjectStore("folders", { keyPath: "id" });
db.createObjectStore("virtual_folders", { keyPath: "id" });
// local_folders deliberately absent.
};
req.onsuccess = () => {
req.result.close();
resolve();
};
req.onerror = () => reject(req.error);
});
const db = await indexedDBManager.openDatabase(DATABASE_CONFIGS.FILES);
const names = Array.from(db.objectStoreNames);
expect(names).toContain("local_folders");
expect(names).toContain("virtual_folders");
expect(db.version).toBe(TARGET_VERSION);
indexedDBManager.closeDatabase(DB_NAME);
});
test("v9 -> latest adds virtual_folders without touching files or folders", async () => {
// Seed a database shaped like the v9 schema: files + folders, no
// virtual_folders yet, with a row in each that must survive the upgrade.
await new Promise<void>((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 9);
req.onupgradeneeded = () => {
const db = req.result;
db.createObjectStore("files", { keyPath: "id" });
db.createObjectStore("folders", { keyPath: "id" });
};
req.onsuccess = () => {
const db = req.result;
const tx = db.transaction(["files", "folders"], "readwrite");
tx.objectStore("files").put({ id: "file-1", folderId: null });
tx.objectStore("folders").put({ id: "folder-1", name: "Kept" });
tx.oncomplete = () => {
db.close();
resolve();
};
tx.onerror = () => reject(tx.error);
};
req.onerror = () => reject(req.error);
});
await indexedDBManager.openDatabase(DATABASE_CONFIGS.FILES);
indexedDBManager.closeDatabase(DB_NAME);
const stores = await getObjectStoreNames();
expect(stores).toContain("virtual_folders");
expect(stores).toContain("local_folders");
expect(stores).toContain("files");
expect(stores).toContain("folders");
const rows = (await readAllFiles()) as Array<Record<string, unknown>>;
expect(rows.map((row) => row.id)).toEqual(["file-1"]);
expect(await indexedDBManager.getDatabaseVersion(DB_NAME)).toBe(
TARGET_VERSION,
);
});
});
@@ -465,7 +465,9 @@ class IndexedDBManager {
export const DATABASE_CONFIGS = {
FILES: {
name: "stirling-pdf-files",
version: 9,
// v10 existed briefly with only one of the two browser-folder stores; v11 declares
// both, so every v10 profile upgrades to a full schema.
version: 11,
stores: [
{
name: "files",
@@ -492,6 +494,27 @@ export const DATABASE_CONFIGS = {
{ name: "createdAt", keyPath: "createdAt", unique: false },
],
},
{
name: "local_folders",
keyPath: "id",
indexes: [{ name: "name", keyPath: "name", unique: false }],
},
// Browser-owned folders (kind "virtual"), deliberately a separate store from
// `folders`: that one is a cache the server sync wipes wholesale on every pull,
// and these rows have no server copy to be restored from.
{
name: "virtual_folders",
keyPath: "id",
indexes: [
{
name: "parentFolderId",
keyPath: "parentFolderId",
unique: false,
},
{ name: "name", keyPath: "name", unique: false },
{ name: "createdAt", keyPath: "createdAt", unique: false },
],
},
],
} as DatabaseConfig,
@@ -0,0 +1,54 @@
/** One file inside a mounted directory, as the file manager lists it. */
export interface DiskFileEntry {
/** Absolute path — the file's identity here; nothing about it is stored. */
path: string;
name: string;
sizeBytes: number;
lastModified: number;
}
export interface DiskDirEntry {
path: string;
name: string;
}
/** What one look at a mounted directory yields. */
export interface DiskListing {
files: DiskFileEntry[];
directories: DiskDirEntry[];
}
export const canListDirectory = false;
/**
* The regular files and subdirectories directly inside `directory` one level, never
* recursive; a subdirectory is listed only when entered.
*/
export async function listDirectory(
_directory: string,
): Promise<DiskListing | null> {
return null;
}
export async function makeDiskDirectory(
_parent: string,
_name: string,
): Promise<string | null> {
return null;
}
/** Read one listed file's bytes as a File, ready for the workbench. */
export async function readDiskFile(
_entry: DiskFileEntry,
): Promise<File | null> {
return null;
}
/** Write a file into a mounted directory, under a name that never clobbers an existing one. */
export async function writeDiskFile(
_directory: string,
_name: string,
_bytes: Blob,
): Promise<string | null> {
return null;
}
@@ -0,0 +1,64 @@
import { describe, expect, test, beforeEach } from "vitest";
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import {
localFolderStorage,
directoryKey,
} from "@app/services/localFolderStorage";
import {
indexedDBManager,
DATABASE_CONFIGS,
} from "@app/services/indexedDBManager";
/**
* A mount is a pointer at a directory, and one directory must never have two pointers
* the rows would be two names for one truth.
*/
describe("localFolderStorage", () => {
beforeEach(() => {
indexedDBManager.closeDatabase(DATABASE_CONFIGS.FILES.name);
globalThis.indexedDB = new IDBFactory();
});
test("directoryKey equates the spellings a case-insensitive filesystem does", () => {
const key = directoryKey("C:\\Users\\Reece\\Downloads");
expect(directoryKey("c:\\users\\reece\\downloads")).toBe(key);
expect(directoryKey("C:\\Users\\Reece\\Downloads\\")).toBe(key);
expect(directoryKey("C:/Users/Reece/Downloads")).toBe(key);
expect(directoryKey("C:\\Users\\\\Reece\\Downloads")).toBe(key);
expect(directoryKey("\\\\server\\share\\docs")).toBe(
directoryKey("//SERVER/share/docs/"),
);
// POSIX paths are genuinely case-sensitive; only the separator rules apply.
expect(directoryKey("/home/Reece/")).toBe(directoryKey("/home/Reece"));
expect(directoryKey("/home/Reece")).not.toBe(directoryKey("/home/reece"));
});
test("mounting the same directory under another spelling hands back the existing record", async () => {
const first = await localFolderStorage.mountDirectory(
"C:\\Users\\Reece\\Downloads",
"Downloads",
);
const again = await localFolderStorage.mountDirectory(
"c:/users/reece/downloads/",
"downloads",
);
expect(again.id).toBe(first.id);
expect(await localFolderStorage.getAllFolders()).toHaveLength(1);
});
test("a subdirectory of a mount gets its own mount; that is the only way to reach it", async () => {
const parent = await localFolderStorage.mountDirectory(
"C:\\Users\\Reece\\Downloads",
"Downloads",
);
const child = await localFolderStorage.mountDirectory(
"C:\\Users\\Reece\\Downloads\\Invoices",
"Invoices",
);
expect(child.id).not.toBe(parent.id);
expect(await localFolderStorage.getAllFolders()).toHaveLength(2);
});
});
@@ -0,0 +1,119 @@
/**
* The record of directories mounted into the file manager (kind "local"): a pointer at
* a directory, nothing more.
*/
import {
FolderId,
FolderRecord,
folderKind,
createFolderId,
pickFolderColor,
} from "@app/types/folder";
import {
indexedDBManager,
DATABASE_CONFIGS,
} from "@app/services/indexedDBManager";
/** One directory, one key — regardless of how the picker spelled the path. */
export function directoryKey(directory: string): string {
let key = directory.replace(/\\/g, "/");
const unc = key.startsWith("//");
key = key.replace(/\/{2,}/g, "/");
if (unc) key = `/${key}`;
if (key.length > 1 && !/^[a-zA-Z]:\/$/.test(key)) {
key = key.replace(/\/+$/, "");
}
if (/^[a-zA-Z]:/.test(key) || unc) {
key = key.toLowerCase();
}
return key;
}
function requireLocalFolder(folder: FolderRecord): void {
if (folderKind(folder) !== "local") {
throw new Error(
`localFolderStorage owns local folders only; got kind "${folderKind(folder)}" for ${folder.id}`,
);
}
}
class LocalFolderStorageService {
private readonly dbConfig = DATABASE_CONFIGS.FILES;
private readonly storeName = "local_folders";
private async getDatabase(): Promise<IDBDatabase> {
return indexedDBManager.openDatabase(this.dbConfig);
}
async getAllFolders(): Promise<FolderRecord[]> {
const db = await this.getDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readonly");
const store = transaction.objectStore(this.storeName);
const request = store.getAll();
request.onerror = () => reject(request.error);
request.onsuccess = () =>
resolve((request.result as FolderRecord[]) ?? []);
});
}
/**
* Mount a directory, or hand back the existing record for one already mounted: two
* rows for one directory would be two names for one truth.
*/
async mountDirectory(directory: string, name: string): Promise<FolderRecord> {
const key = directoryKey(directory);
const folders = await this.getAllFolders();
const existing = folders.find(
(folder) => directoryKey(folder.directory ?? "") === key,
);
if (existing) return existing;
const now = Date.now();
const record: FolderRecord = {
id: createFolderId(),
kind: "local",
name,
parentFolderId: null,
directory,
color: pickFolderColor(name),
createdAt: now,
updatedAt: now,
};
requireLocalFolder(record);
const db = await this.getDatabase();
await new Promise<void>((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
const req = store.put(record);
req.onerror = () => reject(req.error);
req.onsuccess = () => resolve();
});
return record;
}
/** Remove the mount. The directory on disk is untouched, always. */
async removeFolder(id: FolderId): Promise<void> {
const db = await this.getDatabase();
await new Promise<void>((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
const req = store.delete(id);
req.onerror = () => reject(req.error);
req.onsuccess = () => resolve();
});
}
async clearAll(): Promise<void> {
const db = await this.getDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
const request = store.clear();
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
});
}
}
export const localFolderStorage = new LocalFolderStorageService();
@@ -0,0 +1,38 @@
import { writeDiskFile } from "@app/services/localFolderContents";
export interface MountWriteItem {
name: string;
/** Pulled lazily, one file at a time — a batch never sits in memory whole. */
bytes: () => Promise<Blob | null>;
}
/**
* Write named blobs into a mounted directory - the one engine behind both uploading
* into a mount and moving library files into one, so failure accounting and collision
* behaviour cannot drift apart.
*/
export async function writeIntoMount(
directory: string | undefined,
items: MountWriteItem[],
): Promise<{ written: boolean[]; failedCount: number }> {
const written: boolean[] = items.map(() => false);
let failedCount = 0;
for (let i = 0; i < items.length; i++) {
try {
const blob = directory ? await items[i].bytes() : null;
const result =
blob && directory
? await writeDiskFile(directory, items[i].name, blob)
: null;
if (result === null) {
failedCount += 1;
} else {
written[i] = true;
}
} catch (err) {
console.warn("[mountWrites] write into mount failed", err);
failedCount += 1;
}
}
return { written, failedCount };
}
@@ -0,0 +1,78 @@
import { describe, expect, test, beforeEach } from "vitest";
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import { virtualFolderStorage } from "@app/services/virtualFolderStorage";
import { folderStorage } from "@app/services/folderStorage";
import {
indexedDBManager,
DATABASE_CONFIGS,
} from "@app/services/indexedDBManager";
/**
* Virtual folders have no server to be authoritative, so the invariants the
* server enforces for its folders (no cycles, bounded depth, subtree deletes
* that report every removed id) are this module's own responsibility.
*/
describe("virtualFolderStorage", () => {
beforeEach(() => {
indexedDBManager.closeDatabase(DATABASE_CONFIGS.FILES.name);
globalThis.indexedDB = new IDBFactory();
});
test("creates rows stamped virtual, invisible to the server folder cache", async () => {
const created = await virtualFolderStorage.createFolder("Research", null);
expect(created.kind).toBe("virtual");
// Same DB, different store: the server cache must not see it, because a
// sync wipes that cache wholesale and would silently destroy the row.
expect(await folderStorage.getAllFolders()).toEqual([]);
expect(await virtualFolderStorage.getAllFolders()).toEqual([created]);
});
test("the server cache refuses a virtual row outright", async () => {
const virtual = await virtualFolderStorage.createFolder("Research", null);
await expect(folderStorage.upsertFolder(virtual)).rejects.toThrow(
/server folders only/,
);
});
test("refuses to move a folder into its own subtree", async () => {
const parent = await virtualFolderStorage.createFolder("a", null);
const child = await virtualFolderStorage.createFolder("b", parent.id);
const grandchild = await virtualFolderStorage.createFolder("c", child.id);
await expect(
virtualFolderStorage.moveFolder(parent.id, grandchild.id),
).rejects.toThrow(/own subtree/);
await expect(
virtualFolderStorage.moveFolder(parent.id, parent.id),
).rejects.toThrow(/into itself/);
});
test("deleting a folder removes its whole subtree and reports every id", async () => {
const parent = await virtualFolderStorage.createFolder("a", null);
const child = await virtualFolderStorage.createFolder("b", parent.id);
const grandchild = await virtualFolderStorage.createFolder("c", child.id);
const bystander = await virtualFolderStorage.createFolder("keep", null);
const removed = await virtualFolderStorage.deleteFolder(parent.id);
// Every removed id is reported so the caller can unlink files that
// referenced them — the same contract as the server delete.
expect([...removed].sort()).toEqual(
[parent.id, child.id, grandchild.id].sort(),
);
expect(await virtualFolderStorage.getAllFolders()).toEqual([bystander]);
});
test("refuses to nest past the depth cap", async () => {
let parentId = (await virtualFolderStorage.createFolder("d0", null)).id;
for (let i = 1; i < 64; i += 1) {
parentId = (await virtualFolderStorage.createFolder(`d${i}`, parentId))
.id;
}
await expect(
virtualFolderStorage.createFolder("too-deep", parentId),
).rejects.toThrow(/depth limit/);
});
});
@@ -0,0 +1,219 @@
/**
* The system of record for kind "virtual" folders - rows this store owns rather than
* caches.
*/
import {
FolderId,
FolderRecord,
folderKind,
createFolderId,
pickFolderColor,
} from "@app/types/folder";
import {
indexedDBManager,
DATABASE_CONFIGS,
} from "@app/services/indexedDBManager";
/** Mirrors FolderService.MAX_FOLDER_DEPTH so virtual trees can't out-nest server ones. */
const MAX_FOLDER_DEPTH = 64;
function requireVirtualFolder(folder: FolderRecord): void {
if (folderKind(folder) !== "virtual") {
throw new Error(
`virtualFolderStorage owns virtual folders only; got kind "${folderKind(folder)}" for ${folder.id}`,
);
}
}
class VirtualFolderStorageService {
private readonly dbConfig = DATABASE_CONFIGS.FILES;
private readonly storeName = "virtual_folders";
private async getDatabase(): Promise<IDBDatabase> {
return indexedDBManager.openDatabase(this.dbConfig);
}
async getAllFolders(): Promise<FolderRecord[]> {
const db = await this.getDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readonly");
const store = transaction.objectStore(this.storeName);
const request = store.getAll();
request.onerror = () => reject(request.error);
request.onsuccess = () =>
resolve((request.result as FolderRecord[]) ?? []);
});
}
async getFolder(id: FolderId): Promise<FolderRecord | null> {
const db = await this.getDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readonly");
const store = transaction.objectStore(this.storeName);
const request = store.get(id);
request.onerror = () => reject(request.error);
request.onsuccess = () =>
resolve((request.result as FolderRecord | undefined) ?? null);
});
}
/**
* Create a virtual folder under `parent` (null = root), which must itself be
* virtual: hung off a server folder, a server-side delete orphans the subtree.
*/
async createFolder(
name: string,
parentFolderId: FolderId | null,
): Promise<FolderRecord> {
if (parentFolderId !== null) {
await this.requireWithinDepth(parentFolderId);
}
const now = Date.now();
const record: FolderRecord = {
id: createFolderId(),
kind: "virtual",
name,
parentFolderId,
color: pickFolderColor(name),
createdAt: now,
updatedAt: now,
};
await this.put(record);
return record;
}
/** Rename / recolour / re-icon in place. Structure is moveFolder's job. */
async updateFolder(
id: FolderId,
updates: Partial<Pick<FolderRecord, "name" | "color" | "icon">>,
): Promise<FolderRecord | null> {
const existing = await this.getFolder(id);
if (!existing) return null;
const next: FolderRecord = {
...existing,
...updates,
updatedAt: Date.now(),
};
await this.put(next);
return next;
}
/**
* Reparent a folder (null = to root), refusing moves that would make the
* tree lie: under itself or its own descendant (a cycle the subtree would
* fall out of every walk), or deeper than the depth cap.
*/
async moveFolder(
id: FolderId,
newParentId: FolderId | null,
): Promise<FolderRecord | null> {
const existing = await this.getFolder(id);
if (!existing) return null;
if (newParentId !== null) {
if (newParentId === id) {
throw new Error("Cannot move a folder into itself");
}
const ancestors = await this.requireWithinDepth(newParentId);
if (ancestors.has(id)) {
throw new Error("Cannot move a folder into its own subtree");
}
}
const next: FolderRecord = {
...existing,
parentFolderId: newParentId,
updatedAt: Date.now(),
};
await this.put(next);
return next;
}
/**
* Delete a folder and its whole virtual subtree, returning every removed id
* so the caller can unlink files that referenced them mirroring the shape
* of the server delete, which reports removedFolderIds for the same reason.
*/
async deleteFolder(id: FolderId): Promise<FolderId[]> {
const all = await this.getAllFolders();
const childrenByParent = new Map<FolderId | null, FolderRecord[]>();
for (const folder of all) {
const siblings = childrenByParent.get(folder.parentFolderId) ?? [];
siblings.push(folder);
childrenByParent.set(folder.parentFolderId, siblings);
}
// `removed` doubles as the BFS queue (index-walked; shift() would
// reindex the array on every visit).
const removed: FolderId[] = [id];
for (let head = 0; head < removed.length; head++) {
for (const child of childrenByParent.get(removed[head]) ?? []) {
removed.push(child.id);
}
}
const db = await this.getDatabase();
await new Promise<void>((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
transaction.oncomplete = () => resolve();
transaction.onerror = () =>
reject(transaction.error ?? new Error("virtual folder delete failed"));
transaction.onabort = () =>
reject(transaction.error ?? new Error("virtual folder delete aborted"));
for (const folderId of removed) store.delete(folderId);
});
return removed;
}
async clearAll(): Promise<void> {
const db = await this.getDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
const request = store.clear();
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
});
}
private async put(record: FolderRecord): Promise<void> {
requireVirtualFolder(record);
const db = await this.getDatabase();
await new Promise<void>((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
const req = store.put(record);
req.onerror = () => reject(req.error);
req.onsuccess = () => resolve();
});
}
/** Walk from `startId` to the root, returning the ids seen. */
private async requireWithinDepth(startId: FolderId): Promise<Set<FolderId>> {
// One read for the whole store, walked in memory — the chain would
// otherwise cost a serialized IndexedDB round trip per ancestor.
const byId = new Map(
(await this.getAllFolders()).map((folder) => [folder.id, folder]),
);
const seen = new Set<FolderId>();
let cursor: FolderId | null = startId;
while (cursor !== null) {
if (seen.has(cursor)) {
throw new Error("Virtual folder hierarchy contains a cycle");
}
seen.add(cursor);
const parent = byId.get(cursor);
if (parent === undefined) {
throw new Error(`No virtual folder: ${cursor}`);
}
cursor = parent.parentFolderId;
}
// The chain walked is the prospective parent's own ancestry; whatever is
// being placed under it sits one level deeper, so a full-depth chain has
// no room for a child.
if (seen.size >= MAX_FOLDER_DEPTH) {
throw new Error(`Folder depth limit reached (max ${MAX_FOLDER_DEPTH})`);
}
return seen;
}
}
export const virtualFolderStorage = new VirtualFolderStorageService();
@@ -8,12 +8,24 @@ interface SeedFile {
id: string;
name: string;
remoteStorageId: number | null;
folderId?: string;
versionNumber?: number;
toolHistory?: Array<{ toolId: string; timestamp: number }>;
}
/** Seed IDB + register the cloud entries with the server stub. */
async function seedFiles(page: Page, files: SeedFile[]): Promise<void> {
interface SeedFolder {
id: string;
name: string;
}
async function seedFiles(
page: Page,
files: SeedFile[],
// Browser-owned folders, seeded in the same open: a server folder needs an
// authenticated sync the stubbed app never runs.
virtualFolders: SeedFolder[] = [],
): Promise<void> {
// Build the server-side view from the cloud entries so reconcileServerFiles
// sees them as still-existing on the server (otherwise they get detached).
const serverFiles = files
@@ -36,7 +48,7 @@ async function seedFiles(page: Page, files: SeedFile[]): Promise<void> {
route.fulfill({ json: serverFiles }),
);
await page.addInitScript(
({ records, dbVersion }) => {
({ records, vFolders, dbVersion }) => {
const open = window.indexedDB.open("stirling-pdf-files", dbVersion);
open.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
@@ -56,15 +68,36 @@ async function seedFiles(page: Page, files: SeedFile[]): Promise<void> {
});
fStore.createIndex("name", "name", { unique: false });
}
if (!db.objectStoreNames.contains("virtual_folders")) {
const vStore = db.createObjectStore("virtual_folders", {
keyPath: "id",
});
vStore.createIndex("parentFolderId", "parentFolderId", {
unique: false,
});
}
if (!db.objectStoreNames.contains("local_folders")) {
db.createObjectStore("local_folders", { keyPath: "id" });
}
};
open.onsuccess = () => {
const db = open.result;
// Yield the connection if the app ever needs to upgrade, and drop it
// once the writes commit, so the seed never blocks the app's open.
db.onversionchange = () => db.close();
const tx = db.transaction("files", "readwrite");
const tx = db.transaction(["files", "virtual_folders"], "readwrite");
const store = tx.objectStore("files");
const now = Date.now();
for (const folder of vFolders) {
tx.objectStore("virtual_folders").put({
id: folder.id,
kind: "virtual",
name: folder.name,
parentFolderId: null,
createdAt: now,
updatedAt: now,
});
}
for (const f of records) {
store.put({
id: f.id,
@@ -83,7 +116,7 @@ async function seedFiles(page: Page, files: SeedFile[]): Promise<void> {
originalFileId: f.id,
parentFileId: null,
toolHistory: f.toolHistory ?? [],
folderId: null,
folderId: f.folderId ?? null,
remoteStorageId: f.remoteStorageId,
remoteStorageUpdatedAt: f.remoteStorageId ? now : null,
remoteOwnerUsername: f.remoteStorageId ? "testuser" : null,
@@ -97,7 +130,11 @@ async function seedFiles(page: Page, files: SeedFile[]): Promise<void> {
tx.oncomplete = () => db.close();
};
},
{ records: files, dbVersion: DATABASE_CONFIGS.FILES.version },
{
records: files,
vFolders: virtualFolders,
dbVersion: DATABASE_CONFIGS.FILES.version,
},
);
}
@@ -415,6 +452,48 @@ test.describe("Files page", () => {
});
});
test.describe("Opening a file already in the workspace", () => {
test.beforeEach(async ({ page }) => {
await stubStorageApis(page);
await seedFiles(page, [
{ id: "dupe-test", name: "dupe-test.pdf", remoteStorageId: null },
]);
});
test.use({ autoGoto: false });
/**
* Opening a file that is already open has nothing to fetch and nothing to add:
* sending it through materialize-and-add again has no reason to succeed twice.
*/
test("opens it once, and opening it again neither duplicates nor throws", async ({
page,
}) => {
await gotoFilesPage(page);
const card = () =>
page
.locator(".files-page-card:not(.is-folder)")
.filter({ hasText: "dupe-test.pdf" });
await card().dblclick();
await expect(page).not.toHaveURL(/\/files/, { timeout: 5_000 });
await expect(page.locator(".file-sidebar-file-item")).toHaveCount(1, {
timeout: 10_000,
});
// Back to the library and open the same file again.
await page.goto("/files", { waitUntil: "domcontentloaded" });
await expect(card()).toBeVisible({ timeout: 10_000 });
await card().dblclick();
await expect(page).not.toHaveURL(/\/files/, { timeout: 5_000 });
// Still one: the workspace holds the file once, and the app is still up.
await expect(page.locator(".file-sidebar-file-item")).toHaveCount(1, {
timeout: 10_000,
});
await expect(page.getByText(/Something went wrong/i)).toHaveCount(0);
});
});
test.describe("Drag-and-drop wiring", () => {
test.beforeEach(async ({ page }) => {
await stubStorageApis(page);
@@ -499,7 +578,7 @@ test.describe("Files page", () => {
test.describe("Empty-state CTAs", () => {
test.use({ autoGoto: false });
test("renders Upload + Create folder CTAs when grid is empty", async ({
test("renders Upload + New folder CTAs when grid is empty", async ({
page,
}) => {
await stubStorageApis(page);
@@ -519,15 +598,15 @@ test.describe("Files page", () => {
await expect(
page
.locator(".files-page-empty-actions")
.getByRole("button", { name: /Create folder/i }),
.getByRole("button", { name: /New folder/i }),
).toBeVisible();
});
test("Create folder CTA disabled when storage isn't reachable", async ({
test("New folder CTA is disabled when storage isn't reachable", async ({
page,
}) => {
// Storage disabled - the New folder action is gated and the CTA
// should mirror that gating with a disabled state.
// The CTA is the header's control, so it reports the same blocked reason
// rather than offering a click that cannot land.
await stubStorageApis(page, { storageEnabled: false });
await page.goto("/files", { waitUntil: "domcontentloaded" });
await expect(page.locator(".files-page-empty")).toBeVisible({
@@ -535,7 +614,7 @@ test.describe("Files page", () => {
});
const createCta = page
.locator(".files-page-empty-actions")
.getByRole("button", { name: /Create folder/i });
.getByRole("button", { name: /New folder/i });
await expect(createCta).toBeVisible();
await expect(createCta).toBeDisabled();
});
@@ -856,4 +935,120 @@ test.describe("Files page", () => {
expect(after).toBeGreaterThanOrEqual(before + 24);
});
});
test.describe("Folder navigation", () => {
const FOLDER_ID = "11111111-2222-4333-8444-555555555555";
test.beforeEach(async ({ page }) => {
await stubStorageApis(page);
await seedFiles(
page,
[
{ id: "nav-outside", name: "nav-outside.pdf", remoteStorageId: null },
{
id: "nav-inside",
name: "nav-inside.pdf",
remoteStorageId: null,
folderId: FOLDER_ID,
},
],
[{ id: FOLDER_ID, name: "Invoices" }],
);
});
test.use({ autoGoto: false });
const intoFolder = async (page: Page) => {
const tree = page.getByRole("tree", { name: /Folders/i });
await expect(tree).toBeVisible({ timeout: 10_000 });
await tree.getByRole("treeitem", { name: /Invoices/i }).click();
await expect(page).toHaveURL(new RegExp(`/files/${FOLDER_ID}`), {
timeout: 5_000,
});
};
/**
* A breadcrumb is a plain jump to an ancestor. Everything the selection change
* drives - the listing, the folder filters, the path write - has to survive it.
*/
test("clicking a breadcrumb returns to the root without throwing", async ({
page,
}) => {
await page.goto("/files", { waitUntil: "domcontentloaded" });
await intoFolder(page);
const crumbs = page.getByRole("navigation", { name: /Folder path/i });
await expect(crumbs).toBeVisible({ timeout: 5_000 });
await crumbs.getByRole("button", { name: /All files/i }).click();
await expect(page).toHaveURL(/\/files\/?$/, { timeout: 5_000 });
await expect(page.getByText(/Something went wrong/i)).toHaveCount(0);
// Still a working library, not a husk.
await expect(page.getByRole("tree", { name: /Folders/i })).toBeVisible();
});
/** Each folder is its own history entry, so Back walks up the tree rather than
* out of the library, and Forward returns to the folder. */
test("back leaves the folder rather than the library, and forward returns", async ({
page,
}) => {
await page.goto("/files", { waitUntil: "domcontentloaded" });
await intoFolder(page);
const deep = page.url();
await page.goBack();
await expect(page).toHaveURL(/\/files\/?$/, { timeout: 5_000 });
await expect(page.getByText(/Something went wrong/i)).toHaveCount(0);
await page.goForward();
await expect(page).toHaveURL(deep, { timeout: 5_000 });
await expect(page.getByText(/Something went wrong/i)).toHaveCount(0);
});
});
test.describe("Long lists", () => {
test.use({ autoGoto: false });
/**
* A folder can hold thousands of files, so the grid renders a window of them plus
* a spacer at each end rather than the whole list. Needs a real browser: without
* layout the window stands down and everything renders, which is the intended
* fallback but proves nothing about the windowing.
*/
test("renders a window of a long list, not all of it", async ({ page }) => {
const COUNT = 400;
await stubStorageApis(page);
await seedFiles(
page,
Array.from({ length: COUNT }, (_, i) => ({
id: `bulk-${i}`,
name: `bulk-${String(i).padStart(4, "0")}.pdf`,
remoteStorageId: null,
})),
);
await gotoFilesPage(page);
const cards = page.locator(
".files-page-card:not(.files-page-skeleton-card)",
);
const rendered = await cards.count();
expect(rendered).toBeGreaterThan(0);
expect(rendered).toBeLessThan(COUNT / 2);
// The spacers stand in for the rest, so the scroll height still reflects the
// whole folder rather than only what is mounted.
const scroller = page.locator(".files-page-content");
const metrics = await scroller.evaluate((el) => ({
scrollHeight: el.scrollHeight,
clientHeight: el.clientHeight,
}));
expect(metrics.scrollHeight).toBeGreaterThan(metrics.clientHeight * 3);
// Scrolling to the end swaps the window rather than growing it.
const firstBefore = await cards.first().textContent();
await scroller.evaluate((el) => el.scrollTo({ top: el.scrollHeight }));
await expect
.poll(async () => cards.first().textContent(), { timeout: 5_000 })
.not.toBe(firstBefore);
expect(await cards.count()).toBeLessThan(COUNT / 2);
});
});
});
+8 -14
View File
@@ -9,7 +9,7 @@ html[data-app-theme="light"] {
--c-bg-raised: var(--p-white);
--c-surface: var(--p-snow);
--c-surface-raised: var(--p-white);
--c-surface-sunken: var(--p-gray-100);
--c-surface-sunken: var(--p-c-f0f0f0);
--c-input-bg: var(--p-white);
--c-modal-surface: var(--p-white);
--c-hover: var(--p-gray-50);
@@ -144,7 +144,7 @@ html[data-app-theme="midnight"] {
--c-bg-raised: var(--p-zinc-850);
--c-surface: var(--p-c-1a1a1d);
--c-surface-raised: var(--p-zinc-650);
--c-surface-sunken: var(--p-zinc-850);
--c-surface-sunken: var(--p-c-100f0e);
--c-input-bg: var(--c-surface);
--c-modal-surface: var(--c-surface);
--c-hover: var(--p-gray-800);
@@ -204,11 +204,8 @@ html[data-app-theme="custom"] {
--c-bg-raised: color-mix(in srgb, var(--c-primary) 4%, var(--p-white));
--c-surface: color-mix(in srgb, var(--c-primary) 3%, var(--p-snow));
--c-surface-raised: color-mix(in srgb, var(--c-primary) 4%, var(--p-white));
--c-surface-sunken: color-mix(
in srgb,
var(--c-primary) 8%,
var(--p-gray-100)
);
/* Neutral recess: no accent mix, so the sunken well stays a plain grey (not cold, not cream). */
--c-surface-sunken: var(--p-c-f0f0f0);
--c-input-bg: color-mix(in srgb, var(--c-primary) 2%, var(--p-white));
--c-hover: color-mix(in srgb, var(--c-primary) 9%, var(--p-gray-50));
--c-active: color-mix(in srgb, var(--c-primary) 13%, var(--p-gray-100));
@@ -357,11 +354,8 @@ html[data-app-theme="custom"][data-mantine-color-scheme="dark"] {
var(--c-primary) 9%,
var(--p-zinc-775)
);
--c-surface-sunken: color-mix(
in srgb,
var(--c-primary) 8%,
var(--p-zinc-900)
);
/* Neutral recess (see light): no accent mix, so the sunken well stays a plain near-black. */
--c-surface-sunken: var(--p-c-100f0e);
--c-input-bg: var(--c-surface);
--c-hover: color-mix(in srgb, var(--c-primary) 12%, var(--p-zinc-750));
--c-active: color-mix(in srgb, var(--c-primary) 15%, var(--p-zinc-700));
@@ -412,7 +406,7 @@ html[data-app-theme="custom"][data-accent="default"] {
--c-bg-raised: var(--p-white);
--c-surface: var(--p-snow);
--c-surface-raised: var(--p-white);
--c-surface-sunken: var(--p-gray-100);
--c-surface-sunken: var(--p-c-f0f0f0);
--c-input-bg: var(--p-white);
--c-hover: var(--p-gray-50);
--c-active: var(--p-gray-100);
@@ -425,7 +419,7 @@ html[data-app-theme="custom"][data-accent="default"][data-mantine-color-scheme="
--c-bg-raised: var(--p-zinc-850);
--c-surface: var(--p-c-1a1a1d);
--c-surface-raised: var(--p-zinc-775);
--c-surface-sunken: var(--p-zinc-900);
--c-surface-sunken: var(--p-c-100f0e);
--c-input-bg: var(--c-surface);
--c-hover: var(--p-zinc-750);
--c-active: var(--p-zinc-700);
@@ -39,6 +39,7 @@
--p-zinc-100: #f4f4f5;
--p-c-141416: #141416;
--p-c-1a1a1d: #1a1a1d;
--p-c-100f0e: #100f0e;
--p-c-28282d: #28282d;
--p-c-343439: #343439;
--p-blue-400: #60a5fa;
+9 -6
View File
@@ -65,13 +65,16 @@ export interface BaseFileMetadata {
sourceFileIds?: FileId[];
/**
* The cloud folder this file lives in. Semantics:
* - `remoteStorageId == null` file is local-only; folderId MUST be null.
* - `remoteStorageId != null && folderId == null` file is at the cloud root.
* - `remoteStorageId != null && folderId == X` file lives in cloud folder X.
* The folder this file lives in. Semantics by storage state:
* - Cloud-stored (`remoteStorageId != null`): server-authoritative null is
* the cloud root, X is server folder X; the sync overwrites it.
* - Local-only: browser-owned a browser folder's id, or the server folder
* the file was placed in at upload time, which membership the server
* adopts once the save-to-server lands (and which keeps the file visibly
* in its folder if that save fails).
*
* The "Local" pseudo-folder in the UI is the predicate `remoteStorageId == null`;
* it has no corresponding {@code folderId} value. Folders are a server-only concept.
* The "Local" pseudo-folder in the UI is the predicate
* `remoteStorageId == null && folderId == null`.
*/
folderId?: FolderId | null;
@@ -0,0 +1,31 @@
import { describe, expect, test } from "vitest";
import {
diskFolderId,
diskFolderPath,
isDiskFolderId,
} from "@app/types/folder";
/**
* A mount's subdirectories have no stored record, so their ids must carry the
* path itself through a URL, across a reload, for any spelling the OS uses.
*/
describe("disk folder ids", () => {
test("round-trips Windows, POSIX, and non-ASCII paths", () => {
for (const path of [
"C:\\Users\\Reece\\Downloads\\Invoices",
"/home/reece/Documents/Rechnungen 2026",
"D:\\\u041f\u0440\u043e\u0435\u043a\u0442\u044b\\\u0421\u0447\u0435\u0442\u0430",
]) {
const id = diskFolderId(path);
expect(isDiskFolderId(id)).toBe(true);
expect(diskFolderPath(id)).toBe(path);
}
});
test("is URL-safe and distinct from stored folder ids", () => {
const id = diskFolderId("C:\\a+b/c?d");
expect(id).toMatch(/^disk:[A-Za-z0-9_-]+$/);
expect(isDiskFolderId("3f0e2a9c-0000-4000-8000-000000000000")).toBe(false);
expect(diskFolderPath("not-a-disk-id")).toBeNull();
});
});
+49
View File
@@ -37,11 +37,21 @@ export const FOLDER_COLOR_PALETTE = [
/** Members of {@link FOLDER_COLOR_PALETTE}. Use this rather than `string` to keep callers honest. */
export type FolderPaletteColor = (typeof FOLDER_COLOR_PALETTE)[number];
/**
* Three independent features that share a shape, not variants of one: - `server`: in
* the server's database, synced down and cached; needs login and storage to exist.
*/
export type FolderKind = "server" | "virtual" | "local";
/** Persisted folder shape stored in IndexedDB. */
export interface FolderRecord {
id: FolderId;
/** Read through {@link folderKind}, never directly. */
kind?: FolderKind;
name: string;
parentFolderId: FolderId | null;
/** For `local` folders: the directory this record mounts. */
directory?: string;
/** Hex colour - either a palette member or any custom hex from a future picker. */
color?: string;
icon?: string;
@@ -49,6 +59,11 @@ export interface FolderRecord {
updatedAt: number;
}
/** Absent means `server`: server DTOs and rows predating kinds never carry one. */
export function folderKind(folder: Pick<FolderRecord, "kind">): FolderKind {
return folder.kind ?? "server";
}
/**
* Folder tree node - derived from FolderRecord[] for rendering the tree
* navigator. Children are ordered by name (case-insensitive).
@@ -82,6 +97,40 @@ export function parseFolderId(value: unknown): FolderId {
return value as FolderId;
}
/** Subdirectories of a mounted folder are not stored anywhere — the directory is the record. */
const DISK_FOLDER_ID_PREFIX = "disk:";
export function diskFolderId(path: string): FolderId {
const bytes = new TextEncoder().encode(path);
let binary = "";
for (const b of bytes) binary += String.fromCharCode(b);
const b64 = btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
return `${DISK_FOLDER_ID_PREFIX}${b64}` as FolderId;
}
export function isDiskFolderId(id: string): boolean {
return id.startsWith(DISK_FOLDER_ID_PREFIX);
}
/** The path a disk subfolder id encodes, or null for any other id. */
export function diskFolderPath(id: string): string | null {
if (!isDiskFolderId(id)) return null;
const b64 = id
.slice(DISK_FOLDER_ID_PREFIX.length)
.replace(/-/g, "+")
.replace(/_/g, "/");
try {
const binary = atob(b64);
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
return new TextDecoder().decode(bytes);
} catch {
return null;
}
}
export function createFolderId(): FolderId {
return generateId() as FolderId;
}
+48
View File
@@ -0,0 +1,48 @@
.sui-card-rail {
display: flex;
flex-wrap: nowrap;
overflow-x: auto;
/* A real horizontal scroller: keep the bounce, but don't chain to the browser's back gesture. */
overscroll-behavior-x: contain;
/* Room for the scrollbar so it doesn't sit under the last row of item content. */
padding-bottom: 0.25rem;
}
/* Gap scale mirrors Stack / Inline. */
.sui-card-rail--gap-0 {
gap: var(--space-0);
}
.sui-card-rail--gap-0_5 {
gap: var(--space-0_5);
}
.sui-card-rail--gap-1 {
gap: var(--space-1);
}
.sui-card-rail--gap-1_5 {
gap: var(--space-1_5);
}
.sui-card-rail--gap-2 {
gap: var(--space-2);
}
.sui-card-rail--gap-3 {
gap: var(--space-3);
}
.sui-card-rail--gap-4 {
gap: var(--space-4);
}
.sui-card-rail--gap-5 {
gap: var(--space-5);
}
.sui-card-rail--gap-6 {
gap: var(--space-6);
}
.sui-card-rail--gap-8 {
gap: var(--space-8);
}
/* Uniform item sizing: fixed width so items don't stretch/shrink, optional fixed height so a row of
cards is equal-height. Both default to natural sizing when the caller sets no dimension. */
.sui-card-rail > * {
flex: 0 0 var(--sui-card-rail-item-w, auto);
height: var(--sui-card-rail-item-h, auto);
}
@@ -0,0 +1,69 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import CategoryOutlinedIcon from "@mui/icons-material/CategoryOutlined";
import GavelOutlinedIcon from "@mui/icons-material/GavelOutlined";
import LayersOutlinedIcon from "@mui/icons-material/LayersOutlined";
import AltRouteOutlinedIcon from "@mui/icons-material/AltRouteOutlined";
import ScheduleOutlinedIcon from "@mui/icons-material/ScheduleOutlined";
import { CardRail } from "@app/ui/CardRail";
import { OptionCard } from "@app/ui/OptionCard";
const items = [
{
icon: <ShieldOutlinedIcon />,
title: "Security",
desc: "Redact, sanitize, and watermark every document.",
},
{
icon: <CategoryOutlinedIcon />,
title: "Classification",
desc: "Tag each document against your team's labels.",
},
{
icon: <GavelOutlinedIcon />,
title: "Compliance",
desc: "Enforce frameworks and keep an audit trail.",
},
{
icon: <LayersOutlinedIcon />,
title: "Ingestion",
desc: "OCR and flatten documents as they arrive.",
},
{
icon: <AltRouteOutlinedIcon />,
title: "Routing",
desc: "Send finished documents where they belong.",
},
{
icon: <ScheduleOutlinedIcon />,
title: "Retention",
desc: "Archive and expire on your schedule.",
},
];
const meta: Meta<typeof CardRail> = {
title: "Primitives/CardRail",
component: CardRail,
tags: ["autodocs"],
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof CardRail>;
/** A row of equal-size cards that scrolls sideways when they overflow the container. */
export const Default: Story = {
render: () => (
<CardRail itemWidth="16rem" itemHeight="11rem">
{items.map((it) => (
<OptionCard
key={it.title}
icon={it.icon}
title={it.title}
description={it.desc}
cta="Set up"
onSelect={() => {}}
/>
))}
</CardRail>
),
};
+56
View File
@@ -0,0 +1,56 @@
import type {
CSSProperties,
ElementType,
HTMLAttributes,
ReactNode,
} from "react";
import type { StackGap } from "@app/ui/Stack";
import "@app/ui/CardRail.css";
export interface CardRailProps extends HTMLAttributes<HTMLElement> {
/** Token-aligned gap between items (maps to `--space-*`). */
gap?: StackGap;
/** Fixed width for every item (any CSS length); omit to let items size themselves. */
itemWidth?: string;
/** Fixed height for every item; omit for natural height. Equal heights line item footers up. */
itemHeight?: string;
as?: ElementType;
children?: ReactNode;
}
/**
* A horizontal row of equal-sized items that scrolls sideways rather than wrapping - the "rail" of
* cards motif (template galleries, tier pickers, at-a-glance strips). The scrolling sibling to
* {@link Stack} (vertical) and {@link Inline} (horizontal, wraps): it keeps items on one line,
* contains the overscroll so it doesn't trigger the browser back-gesture, and sizes every child
* uniformly so their footers align.
*/
export function CardRail({
gap = "3",
itemWidth,
itemHeight,
as,
className,
style,
children,
...rest
}: CardRailProps) {
const Tag: ElementType = as ?? "div";
const vars = {
...(itemWidth ? { "--sui-card-rail-item-w": itemWidth } : {}),
...(itemHeight ? { "--sui-card-rail-item-h": itemHeight } : {}),
...style,
} as CSSProperties;
const classes = [
"sui-card-rail",
`sui-card-rail--gap-${gap}`,
className ?? "",
]
.filter(Boolean)
.join(" ");
return (
<Tag className={classes} style={vars} {...rest}>
{children}
</Tag>
);
}
+1 -23
View File
@@ -19,29 +19,7 @@
color: var(--color-section-label);
}
/* An (i) affordance beside the label: the explanation lives in its tooltip
rather than as permanent subtext under the control. */
.sui-field__info {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
background: none;
color: var(--c-text-subtle);
cursor: pointer;
line-height: 0;
}
.sui-field__info:hover {
color: var(--c-text);
}
.sui-field__info:focus-visible {
outline: 2px solid var(--c-primary);
outline-offset: 2px;
border-radius: 999px;
}
/* The label's (i) affordance is the shared InfoTooltip primitive (@app/ui/InfoTooltip). */
.sui-field__required {
/* The base red is a fill colour; as text on the form background it only
+2 -35
View File
@@ -5,7 +5,7 @@ import {
type ReactElement,
type ReactNode,
} from "react";
import { Tooltip } from "@mantine/core";
import { InfoTooltip } from "@app/ui/InfoTooltip";
import "@app/ui/FormField.css";
export interface FormFieldProps {
@@ -77,40 +77,7 @@ export function FormField({
)}
</label>
)}
{info && (
<Tooltip
label={info}
multiline
w={260}
withArrow
position="top"
events={{ hover: true, focus: true, touch: true }}
>
<button
type="button"
className="sui-field__info"
aria-label={
typeof info === "string" ? info : "More information"
}
>
<svg
viewBox="0 0 24 24"
width="14"
height="14"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
</button>
</Tooltip>
)}
{info && <InfoTooltip label={info} />}
</div>
)}
<div className="sui-field__control">{child}</div>
@@ -0,0 +1,31 @@
.sui-icon-picker__grid {
display: grid;
grid-template-columns: repeat(5, 2.25rem);
gap: 0.25rem;
padding: 0.375rem;
}
.sui-icon-picker__option {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border: 1px solid transparent;
border-radius: var(--radius-md);
background: transparent;
color: var(--c-text-muted);
cursor: pointer;
}
.sui-icon-picker__option:hover {
background: var(--c-hover);
color: var(--c-text);
}
.sui-icon-picker__option--selected {
border-color: var(--c-primary);
background: var(--c-primary-subtle);
/* Accent tuned to clear the contrast floor on the tinted chip (light + dark). */
color: var(--c-accent-text);
}
@@ -0,0 +1,55 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
import LabelOutlinedIcon from "@mui/icons-material/LabelOutlined";
import LayersOutlinedIcon from "@mui/icons-material/LayersOutlined";
import FolderOutlinedIcon from "@mui/icons-material/FolderOutlined";
import BoltOutlinedIcon from "@mui/icons-material/BoltOutlined";
import ScheduleOutlinedIcon from "@mui/icons-material/ScheduleOutlined";
import AutoAwesomeOutlinedIcon from "@mui/icons-material/AutoAwesomeOutlined";
import { IconPicker, type IconPickerOption } from "@app/ui/IconPicker";
const sx = { fontSize: "1.25rem" } as const;
const OPTIONS: IconPickerOption[] = [
{ key: "shield", label: "Shield", node: <ShieldOutlinedIcon sx={sx} /> },
{ key: "lock", label: "Lock", node: <LockOutlinedIcon sx={sx} /> },
{ key: "label", label: "Label", node: <LabelOutlinedIcon sx={sx} /> },
{ key: "layers", label: "Layers", node: <LayersOutlinedIcon sx={sx} /> },
{ key: "folder", label: "Folder", node: <FolderOutlinedIcon sx={sx} /> },
{ key: "bolt", label: "Bolt", node: <BoltOutlinedIcon sx={sx} /> },
{
key: "schedule",
label: "Schedule",
node: <ScheduleOutlinedIcon sx={sx} />,
},
{
key: "sparkle",
label: "Sparkle",
node: <AutoAwesomeOutlinedIcon sx={sx} />,
},
];
const meta: Meta<typeof IconPicker> = {
title: "Primitives/IconPicker",
component: IconPicker,
tags: ["autodocs"],
parameters: { layout: "centered" },
};
export default meta;
type Story = StoryObj<typeof IconPicker>;
/** Click the glyph to open the grid and choose a new icon from the supplied set. */
export const Default: Story = {
render: () => {
const [value, setValue] = useState("shield");
return (
<IconPicker
value={value}
onChange={setValue}
options={OPTIONS}
ariaLabel="Icon"
/>
);
},
};
@@ -0,0 +1,78 @@
import { useState, type ReactNode } from "react";
import { ActionIcon } from "@app/ui/ActionIcon";
import { Dropdown } from "@app/ui/Dropdown";
import "@app/ui/IconPicker.css";
export interface IconPickerOption {
/** Stable identifier stored as the picked value. */
key: string;
/** The glyph to show, sized by the caller. */
node: ReactNode;
/** Accessible name for this option (falls back to the key). */
label?: string;
}
export interface IconPickerProps {
/** The picked option's key. */
value: string;
onChange: (key: string) => void;
/** The icons to choose from, in display order. The caller supplies the set. */
options: IconPickerOption[];
/** Accessible name for the trigger (e.g. "Icon"). */
ariaLabel: string;
size?: "sm" | "md" | "lg";
}
/**
* Pick an icon from a caller-supplied set. The chosen glyph is the trigger; the menu is a grid. The
* icon set is injected (via {@link options}) rather than baked in, so any surface - a pipeline, a
* watched folder, an automation - passes its own vocabulary and shares the one control.
*/
export function IconPicker({
value,
onChange,
options,
ariaLabel,
size = "sm",
}: IconPickerProps) {
// Controlled so a grid button (not a Dropdown.Item) can close the menu on pick.
const [open, setOpen] = useState(false);
const selected = options.find((option) => option.key === value) ?? options[0];
function pick(key: string) {
onChange(key);
setOpen(false);
}
return (
<Dropdown.Root open={open} onOpenChange={setOpen} align="start">
<Dropdown.Trigger>
<ActionIcon variant="secondary" size={size} aria-label={ariaLabel}>
{selected?.node}
</ActionIcon>
</Dropdown.Trigger>
<Dropdown.Menu>
<div className="sui-icon-picker__grid">
{options.map((option) => {
const isSelected = option.key === value;
return (
<button
key={option.key}
type="button"
className={
"sui-icon-picker__option" +
(isSelected ? " sui-icon-picker__option--selected" : "")
}
aria-label={option.label ?? option.key}
aria-pressed={isSelected}
onClick={() => pick(option.key)}
>
{option.node}
</button>
);
})}
</div>
</Dropdown.Menu>
</Dropdown.Root>
);
}
@@ -0,0 +1,19 @@
.sui-info {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
border: none;
background: none;
color: var(--c-text-subtle);
cursor: pointer;
line-height: 0;
}
.sui-info:hover {
color: var(--c-text);
}
.sui-info:focus-visible {
outline: 2px solid var(--c-primary);
outline-offset: 2px;
border-radius: 999px;
}
@@ -0,0 +1,30 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { InfoTooltip } from "@app/ui/InfoTooltip";
const meta: Meta<typeof InfoTooltip> = {
title: "Primitives/InfoTooltip",
component: InfoTooltip,
tags: ["autodocs"],
parameters: { layout: "centered" },
args: {
label: "The folder (key prefix) within the bucket to watch.",
position: "top",
},
};
export default meta;
type Story = StoryObj<typeof InfoTooltip>;
/** Hover or focus the (i) to reveal the explanation. */
export const Default: Story = {};
/** Inline beside a label, the way FormField renders it. */
export const BesideLabel: Story = {
render: (args) => (
<span
style={{ display: "inline-flex", alignItems: "center", gap: "0.25rem" }}
>
<span style={{ fontSize: "0.8125rem", fontWeight: 600 }}>Folder</span>
<InfoTooltip {...args} />
</span>
),
};
@@ -0,0 +1,58 @@
import type { ReactNode } from "react";
import { Tooltip, type FloatingPosition } from "@mantine/core";
import "@app/ui/InfoTooltip.css";
export interface InfoTooltipProps {
/** The explanation shown in the tooltip on hover/focus. */
label: ReactNode;
/** Accessible name for the button. Defaults to the label when it's a string. */
ariaLabel?: string;
/** Which side the tooltip opens on. Default "top". */
position?: FloatingPosition;
}
/**
* The app's standard inline info affordance: a small, muted (i) that reveals supplementary text in a
* hover/focus tooltip, without taking permanent space. Used behind form labels ({@link FormField})
* and anywhere a control needs a hint - one implementation so every (i) reads and behaves the same.
*/
export function InfoTooltip({
label,
ariaLabel,
position = "top",
}: InfoTooltipProps) {
return (
<Tooltip
label={label}
multiline
w={260}
withArrow
position={position}
events={{ hover: true, focus: true, touch: true }}
>
<button
type="button"
className="sui-info"
aria-label={
ariaLabel ?? (typeof label === "string" ? label : "More information")
}
>
<svg
viewBox="0 0 24 24"
width="14"
height="14"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
</button>
</Tooltip>
);
}
@@ -0,0 +1,84 @@
.sui-option-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.5rem;
/* Fill whatever height the parent row establishes, so footers align across a row of cards. */
height: 100%;
}
/* Icon and title share a row. */
.sui-option-card__head {
display: flex;
align-items: center;
gap: 0.625rem;
}
.sui-option-card__icon {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: var(--radius-md);
background: var(--c-primary-subtle);
color: var(--c-accent-fg, var(--c-primary));
}
.sui-option-card__title {
margin: 0;
font-size: 0.9375rem;
font-weight: 640;
color: var(--c-text);
}
.sui-option-card__desc {
margin: 0;
font-size: 0.8125rem;
line-height: 1.45;
color: var(--c-text-subtle);
/* Clamp long descriptions with a trailing ellipsis. No flex-grow: stretching the box defeats
-webkit-line-clamp and hard-clips mid-word instead. */
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: var(--sui-option-card-lines, 3);
}
/* Pin whatever ends the card (the CTA or a note) to the foot, so footers line up across cards
without flex-growing the clamped blurb above. */
.sui-option-card > :last-child {
margin-top: auto;
}
.sui-option-card__foot {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.8125rem;
font-weight: 620;
/* Accent text tuned to clear the contrast floor on the card surface (light + dark), unlike raw
--c-primary which is too light for small text. */
color: var(--c-accent-text);
}
/* Disabled cards recede onto the sunken surface: every label goes muted so the card reads clearly
greyed against the usable ones, while the icon keeps a faint raised chip in its own colour so it
stays distinct from the greyed text rather than flattening into it. */
.sui-option-card--disabled {
background: var(--c-surface-sunken);
cursor: default;
}
.sui-option-card--disabled .sui-option-card__icon {
background: var(--c-surface);
color: var(--c-text-subtle);
}
.sui-option-card--disabled .sui-option-card__title,
.sui-option-card--disabled .sui-option-card__desc,
.sui-option-card--disabled .sui-option-card__foot {
color: var(--c-text-muted);
font-weight: 600;
}
@@ -0,0 +1,103 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import CategoryOutlinedIcon from "@mui/icons-material/CategoryOutlined";
import GavelOutlinedIcon from "@mui/icons-material/GavelOutlined";
import ArrowForwardRoundedIcon from "@mui/icons-material/ArrowForwardRounded";
import LockOutlinedIcon from "@mui/icons-material/LockOutlined";
import { OptionCard } from "@app/ui/OptionCard";
const setUp = (
<>
Set up
<ArrowForwardRoundedIcon style={{ fontSize: "1rem" }} />
</>
);
const comingSoon = (
<>
<LockOutlinedIcon style={{ fontSize: "0.95rem" }} />
Coming soon
</>
);
const meta: Meta<typeof OptionCard> = {
title: "Primitives/OptionCard",
component: OptionCard,
tags: ["autodocs"],
parameters: { layout: "padded" },
args: {
icon: <ShieldOutlinedIcon />,
title: "Security",
description:
"Redact sensitive information, strip active content, and watermark every document.",
cta: setUp,
disabled: false,
onSelect: () => {},
},
argTypes: {
icon: { control: false },
cta: { control: false },
note: { control: false },
onSelect: { control: false },
descriptionLines: { control: { type: "number", min: 1, max: 6 } },
},
decorators: [
(S) => (
<div style={{ width: "16rem", height: "12rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof OptionCard>;
/** Toggle `disabled`, edit the title/description, change the clamp in controls. */
export const Playground: Story = {};
/** Inert: no click or hover, muted, with a note in place of the CTA. */
export const Disabled: Story = {
args: { disabled: true, note: comingSoon },
};
/** The gallery use case: a row of selectable options with one disabled. */
export const Gallery: Story = {
decorators: [
(S) => (
<div style={{ width: "100%" }}>
<S />
</div>
),
],
render: () => (
<div style={{ display: "flex", gap: "0.75rem", height: "12rem" }}>
<div style={{ flex: "0 0 16rem" }}>
<OptionCard
icon={<ShieldOutlinedIcon />}
title="Security"
description="Redact sensitive information, strip active content, and watermark every document."
cta={setUp}
onSelect={() => {}}
/>
</div>
<div style={{ flex: "0 0 16rem" }}>
<OptionCard
icon={<CategoryOutlinedIcon />}
title="Classification"
description="Identify each document's type against your team's labels and tag it automatically."
cta={setUp}
onSelect={() => {}}
/>
</div>
<div style={{ flex: "0 0 16rem" }}>
<OptionCard
icon={<GavelOutlinedIcon />}
title="Compliance"
description="Enforce your regulatory frameworks and keep an audit trail of every change."
disabled
note={comingSoon}
/>
</div>
</div>
),
};
@@ -0,0 +1,84 @@
import type { CSSProperties, KeyboardEvent, ReactNode } from "react";
import { Card } from "@app/ui/Card";
import "@app/ui/OptionCard.css";
export interface OptionCardProps {
/** Leading glyph, shown in a tinted chip. */
icon: ReactNode;
title: ReactNode;
/** Short blurb under the title; clamped to {@link descriptionLines} lines. */
description?: ReactNode;
/**
* Footer shown when the card is selectable - typically a call to action like "Set up ->". Pinned
* to the bottom edge so footers line up across a row of cards.
*/
cta?: ReactNode;
/**
* When true the card is inert (no click, no hover) and recedes to a muted, sunken treatment.
* {@link note} replaces the CTA to say why (e.g. a "coming soon" or lock chip).
*/
disabled?: boolean;
note?: ReactNode;
/** Lines the description clamps to before ellipsis. Default 3. */
descriptionLines?: number;
/** Fires when a selectable card is clicked or activated by keyboard. Ignored when disabled. */
onSelect?: () => void;
className?: string;
}
/**
* A choice presented as a titled card: a tinted icon chip, a title, a clamped blurb, and a footer (a
* CTA when selectable, a muted note when not). A primitive for the recurring "pick one of these"
* motif (template galleries, feature pickers) so its layout, disabled treatment and select a11y are
* shared rather than re-styled per feature.
*/
export function OptionCard({
icon,
title,
description,
cta,
disabled = false,
note,
descriptionLines = 3,
onSelect,
className,
}: OptionCardProps) {
const interactive = !disabled && !!onSelect;
function onKeyDown(event: KeyboardEvent<HTMLDivElement>) {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onSelect?.();
}
}
return (
<Card
interactive={interactive}
className={[
"sui-option-card",
disabled ? "sui-option-card--disabled" : "",
className ?? "",
]
.filter(Boolean)
.join(" ")}
style={{ "--sui-option-card-lines": descriptionLines } as CSSProperties}
onClick={interactive ? onSelect : undefined}
role={interactive ? "button" : undefined}
tabIndex={interactive ? 0 : undefined}
onKeyDown={interactive ? onKeyDown : undefined}
aria-disabled={disabled || undefined}
>
<div className="sui-option-card__head">
<span className="sui-option-card__icon" aria-hidden>
{icon}
</span>
<h3 className="sui-option-card__title">{title}</h3>
</div>
{description && <p className="sui-option-card__desc">{description}</p>}
{(disabled ? note : cta) && (
<span className="sui-option-card__foot">{disabled ? note : cta}</span>
)}
</Card>
);
}
+4
View File
@@ -10,6 +10,10 @@ export * from "@app/ui/ToggleSwitch";
export * from "@app/ui/ProgressBar";
export * from "@app/ui/MetricCard";
export * from "@app/ui/NodeCard";
export * from "@app/ui/OptionCard";
export * from "@app/ui/CardRail";
export * from "@app/ui/IconPicker";
export * from "@app/ui/InfoTooltip";
export * from "@app/ui/NavItem";
export * from "@app/ui/NavSurface";
export * from "@app/ui/Surface";
@@ -0,0 +1,62 @@
/**
* Unit tests for fitThumbs, which sizes the mobile scanner's thumbnail strip.
*
* The page deliberately never scrolls, so the strip shrinks to fit instead.
* These pin the invariants that keep it from swallowing the camera preview.
*/
import { describe, test, expect } from "vitest";
import { fitThumbs, THUMB_SIZES } from "@app/utils/mobileScannerThumbs";
const TOTALS = [1, 3, 9, 21, 40];
const VIEWPORTS = [
{ width: 240, height: 320 },
{ width: 1024, height: 1366 },
];
describe("fitThumbs", () => {
test("a zero viewport falls back to the largest thumb and no height cap", () => {
expect(fitThumbs(3, 0, 0)).toEqual({
thumbSize: THUMB_SIZES[0],
stripMaxHeight: undefined,
});
expect(fitThumbs(3, 375, 0)).toEqual({
thumbSize: THUMB_SIZES[0],
stripMaxHeight: undefined,
});
expect(fitThumbs(3, 0, 812)).toEqual({
thumbSize: THUMB_SIZES[0],
stripMaxHeight: undefined,
});
});
test("stripMaxHeight never exceeds 45% of the viewport height", () => {
for (const { width, height } of VIEWPORTS) {
for (const total of TOTALS) {
const { stripMaxHeight } = fitThumbs(total, width, height);
expect(stripMaxHeight).toBeLessThanOrEqual(height * 0.45);
}
}
});
test("thumbSize never grows as more images are added", () => {
for (const { width, height } of VIEWPORTS) {
let previous = Number.POSITIVE_INFINITY;
for (let total = 1; total <= 60; total++) {
const { thumbSize } = fitThumbs(total, width, height);
expect(thumbSize).toBeLessThanOrEqual(previous);
previous = thumbSize;
}
}
});
test("thumbSize is always one of the allowed sizes", () => {
for (const { width, height } of VIEWPORTS) {
for (const total of TOTALS) {
expect(THUMB_SIZES).toContain(
fitThumbs(total, width, height).thumbSize,
);
}
}
});
});

Some files were not shown because too many files have changed in this diff Show More