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.
This commit is contained in:
Reece
2026-09-01 15:53:09 +01:00
parent 90b6762869
commit 056de6d9ee
9 changed files with 125 additions and 225 deletions
@@ -234,8 +234,7 @@ export default function FileManagerView() {
} else if (foldersById.has(param as FolderId)) {
setCurrentFolderId(param as FolderId);
} else if (isDiskFolderId(param) && resolveDiskFolder(param as FolderId)) {
// A mount subdirectory deep link: its record is rebuilt from the id,
// and the map will carry it on the next render.
// A mount subdirectory deep link: rebuilt from the id, mapped next render.
setCurrentFolderId(param as FolderId);
} else {
setCurrentFolderId(ROOT_FOLDER_ID);
@@ -300,9 +299,7 @@ export default function FileManagerView() {
}
const lc = search.toLowerCase();
const matched = folders.folders.filter((f) => {
// The Cloud tab is the server's view: browser folders hold files the
// tab's file filter will never list (a count with an empty room), and
// mounts aren't on the server at all.
// The Cloud tab is the server's view: browser folders and mounts aren't on it.
if (currentTab === "cloud" && folderKind(f) !== "server") return false;
if (search) {
// Subtree-wide name match; exclude the current folder itself.
@@ -325,9 +322,7 @@ export default function FileManagerView() {
// Tab overrides folder navigation for Local/Recent/Shared.
switch (currentTab) {
case "local":
// Local is the pseudo-folder for files that live nowhere: no server
// copy AND no folder membership (cf. file.ts). A local file placed in
// a browser folder is IN that folder, not loose here too.
// Local means no server copy AND no folder membership - not both places.
return allFiles.filter(
(f) => f.remoteStorageId == null && (f.folderId ?? null) === null,
);
@@ -457,8 +452,7 @@ export default function FileManagerView() {
[foldersById],
);
// The directory is the source of truth: its contents are read fresh off
// the disk whenever the user is inside the folder, never ingested to show.
// Read-through: the directory is the source of truth, never ingested to show.
const currentFolder = currentFolderId
? folders.foldersById.get(currentFolderId)
: undefined;
@@ -469,16 +463,13 @@ export default function FileManagerView() {
const { setError: setFolderError, registerDiskSubfolders } = folders;
const [diskEntries, setDiskEntries] = useState<DiskFileEntry[]>([]);
const [diskLoading, setDiskLoading] = useState(false);
// Bumped after this view writes into the directory (an upload while inside
// the mount), so the listing re-reads without leaving and re-entering.
// Bumped when this view writes into the directory, so the listing re-reads.
const [diskRefreshTick, setDiskRefreshTick] = useState(0);
useEffect(() => {
if (!currentLocalDirectory || !canListDirectory) {
setDiskEntries([]);
// Also stand the loading flag down: when the user navigates OUT of a
// mount mid-listing, the in-flight finally skips its reset (cancelled),
// and this branch is the only code that runs — without the reset the
// skeleton covers every folder for the rest of the session.
// 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;
}
@@ -527,9 +518,8 @@ export default function FileManagerView() {
return () => {
cancelled = true;
};
// The stable setter, not the context object: that changes identity on
// every folder mutation including the setError call above, which would
// make a failing listing re-trigger itself.
// 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,
@@ -539,8 +529,7 @@ export default function FileManagerView() {
t,
]);
// Opening a disk file loads its bytes into the workbench — the one moment
// anything leaves the disk, and only because the user asked to work on it.
// The one moment bytes leave the disk, and only because the user asked.
const openDiskFile = useCallback(
async (entry: DiskFileEntry) => {
try {
@@ -574,9 +563,7 @@ export default function FileManagerView() {
// 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 mounted folder the listing IS the directory: its
// subdirectories (registered as folders when listed) and its files.
// Storage rows don't apply there.
// Inside a mount the listing is the directory; storage rows don't apply.
if (currentLocalDirectory) {
const needle = search.toLowerCase();
const compare: Record<
@@ -708,8 +695,7 @@ export default function FileManagerView() {
const target =
currentTab === "all" || currentTab === "cloud" ? currentFolderId : null;
const targetFolder = target ? folders.foldersById.get(target) : undefined;
// A mount's contents ARE its directory: the upload writes straight to
// the disk, never detouring through app storage.
// A mount's upload writes straight to disk, never through app storage.
if (targetFolder && folderKind(targetFolder) === "local") {
const { failedCount } = await writeIntoMount(
targetFolder.directory,
@@ -727,10 +713,8 @@ export default function FileManagerView() {
setDiskRefreshTick((tick) => tick + 1);
return;
}
// Everywhere else the file is BORN in the folder — membership set
// atomically with the stub, not by a move that could fail afterwards.
// For a server folder that membership is local until the save-to-server
// lands; moveFilesTo runs that sync (and is a no-op placement-wise).
// 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,
@@ -1108,11 +1092,9 @@ export default function FileManagerView() {
[selectedFiles, fileMap],
);
// Per-destination availability for the New-folder menu. The reasons render
// inline as the disabled item's caption — the reason IS the information —
// and the server one comes through a build seam, because what actually
// blocks a server folder differs by platform (desktop's local mode has no
// server at all, not a storage setting to flip).
// Per-destination availability for the New-folder menu; the reason renders as the
// disabled item's caption. The server one comes through a build seam because what
// blocks it differs by platform - desktop's local mode has no server at all.
const serverFolderDisabledReason = useServerFolderBlock() ?? undefined;
const { addLocalFolder, createFolderHere, createFolderHereBlockedReason } =
@@ -1120,9 +1102,7 @@ export default function FileManagerView() {
// null = New folder actionable; string = disabled tooltip reason.
const newFolderDisabledReason: string | null = useMemo(() => {
// Folders only render in the All/Cloud views, so creating one from any
// other tab would appear to do nothing. (Folders are NOT cloud-only —
// this is about which views show them, not where they can exist.)
// Only All/Cloud render folders, so creating one elsewhere would look inert.
if (
currentTab === "local" ||
currentTab === "recent" ||
@@ -1134,9 +1114,8 @@ export default function FileManagerView() {
"Switch to All or Cloud to create folders.",
);
}
// Inside a server folder the subfolder inherits kind server, so the
// server-side blockers apply to the button itself — otherwise the dialog
// opens only to fail at submit with a raw error.
// 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" &&
@@ -1144,10 +1123,8 @@ export default function FileManagerView() {
) {
return serverFolderDisabledReason;
}
// The web root creates on the server or not at all, so the same blockers
// gate the button there. Desktop's root menu stays clickable regardless:
// "Add local folder" needs no server, and the server item explains its
// own disabled state inline.
// The web root creates on the server or not at all. Desktop's stays clickable:
// a local folder needs no server, and the server item explains itself inline.
if (
folders.currentFolderId === null &&
!canPickDirectory &&
@@ -1197,8 +1174,7 @@ export default function FileManagerView() {
const handleRefresh = async () => {
setRefreshing(true);
try {
// Inside a mount, "refresh" means the directory too — the
// listing is read-through and only re-reads when told to.
// In a mount, refresh means the directory: the listing only re-reads when told.
if (currentLocalDirectory) {
setDiskRefreshTick((tick) => tick + 1);
}
@@ -1271,10 +1247,8 @@ export default function FileManagerView() {
</span>
</Tooltip>
) : folders.currentFolderId !== null || !canPickDirectory ? (
// Inside a folder there is nothing to choose: the subfolder
// inherits its parent's kind. On the web the root offers no
// choice either — folders live on the server, full stop —
// so both cases are a plain click → name dialog.
// Nothing to choose: a subfolder inherits its parent's kind, and on
// the web everything lives on the server. Straight to the dialog.
<Button
variant="secondary"
size="sm"
@@ -1288,9 +1262,7 @@ export default function FileManagerView() {
{t("filesPage.newFolder", "New folder")}
</Button>
) : (
// Desktop root: the button IS the menu. The destinations
// are peers — neither deserves to be the hidden one behind
// a chevron — so every click shows both.
// Desktop root: two peer destinations, so the button is the menu.
<Menu shadow="md" position="bottom-end" withinPortal>
<Menu.Target>
<Button
@@ -1977,10 +1949,8 @@ export default function FileManagerView() {
// identical regardless of where the user clicks from.
onEmptyUpload={() => fileInputRef.current?.click()}
onEmptyCreateFolder={createFolderHere}
// The CTA is a single-click shortcut, so it also blocks when
// the shortcut has nothing safe to do (web root, no server) —
// unlike the header button, whose root menu still offers the
// explicit choices.
// A single-click shortcut, so it also blocks where it has nothing safe
// to do, unlike the header button whose menu still offers the choices.
newFolderDisabledReason={
newFolderDisabledReason ?? createFolderHereBlockedReason
}
@@ -2069,10 +2039,8 @@ export default function FileManagerView() {
<MoveToFolderDialog
opened={moveDialog.open}
onClose={closeMoveDialog}
// Only real destinations. Files can go anywhere — a mount takes them
// by writing to its directory — but a FOLDER can only move within
// its own kind, and never into a mount (a directory's subfolders are
// the filesystem's business).
// 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;
@@ -272,8 +272,7 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
const submitFolderName = useCallback(
async (name: string) => {
if (folderNameDialog.mode === "new") {
// The kind was chosen before the dialog opened (the New-folder menu);
// it only matters at the root — a subfolder inherits its parent's.
// Chosen before the dialog opened, and only used at the root.
await folders.createFolder(
name,
folderNameDialog.parentId ?? folders.currentFolderId,
@@ -312,10 +311,8 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
const moveFilesTo = useCallback(
async (fileIds: FileId[], folderId: FolderId | null) => {
if (fileIds.length === 0) return;
// fileMap is a render-time snapshot, and callers move files they
// created moments ago (an upload straight into a folder) — those ids
// aren't in any snapshot yet. Storage is the truth; falling back to it
// keeps a just-born file from silently dropping out of the move.
// 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),
@@ -331,12 +328,9 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
const targetKind = targetFolder ? folderKind(targetFolder) : null;
if (targetKind === "local") {
// Being "in" a mounted folder means being ON the disk: the move
// writes each file into the directory (the read-through listing
// shows it at once) and retires the app-side copy — only after the
// bytes verifiably landed. Server files stay in the library: their
// home is the server, and a disk copy would fork the document's
// identity.
// In a mount means on the disk: write each file into the directory, then
// retire the app-side copy once the bytes verifiably landed. Server files
// stay put - a disk copy would fork the document's identity.
const { written, failedCount } = await writeIntoMount(
targetFolder?.directory,
localOnly.map((stub) => ({
@@ -348,13 +342,11 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
.filter((_, i) => written[i])
.map((stub) => stub.id);
if (movedIds.length > 0) {
// Take the superseded versions with it, or their bytes sit in
// storage forever - invisible, because listings only show leaves.
// 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, possibly two things to say — the user asked to
// move N files and needs the whole account of what didn't.
// One error slot, two possible failures: report both.
const notices: string[] = [];
if (failedCount > 0) {
notices.push(
@@ -382,10 +374,8 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
}
if (targetKind === "virtual") {
// A virtual folder is browser-owned, so membership is too: local
// files just point their folderId at it — no upload, no server call.
// Server files stay out: their folder membership belongs to the
// server, and the next sync would silently snap them back.
// Browser-owned, so membership is too: local files just point folderId at
// it. Server files stay out or the next sync snaps them back.
if (cloudFiles.length > 0) {
folders.setError(
t(
@@ -98,10 +98,9 @@ interface FolderContextValue {
reason?: "endpoint-missing" | "network" | "server" | "client";
}>;
/**
* Create a folder. With a parent, the kind is the parent's — a subtree is
* one kind throughout, since each kind has its own system of record and a
* mixed chain would mean an ancestry no single store can vouch for. At the
* root, `kind` decides, defaulting to server.
* Create a folder. With a parent the kind is the parent's - one subtree, one
* system of record, since a mixed chain has no store that can vouch for it. At
* the root `kind` decides, defaulting to server.
*/
createFolder: (
name: string,
@@ -119,22 +118,19 @@ interface FolderContextValue {
) => Promise<FolderRecord | null>;
deleteFolder: (id: FolderId) => Promise<FolderId[]>;
/**
* Mount a directory on the machine as a local folder. Idempotent per
* directory. Removing the mount later goes through {@link deleteFolder};
* the directory itself is never touched by either.
* Mount a directory as a local folder, idempotent per directory. Unmounting goes
* through {@link deleteFolder}; neither touches the directory itself.
*/
mountLocalFolder: (directory: string, name: string) => Promise<FolderRecord>;
/**
* Subdirectories a mount listing found under `parentId`. They are not
* stored — the directory is the record so each listing replaces the
* previous set for that parent, and a directory removed on disk drops out
* on the next look.
* Subdirectories a mount listing found under `parentId`. Not stored - the
* directory is the record - so each listing replaces the previous set, and one
* removed on disk drops out on the next look.
*/
registerDiskSubfolders: (parentId: FolderId, records: FolderRecord[]) => void;
/**
* Rebuild the records behind a disk-subfolder id (a `/files/<id>` link
* arriving before any listing ran). True when the id sits under a known
* mount and its chain is now registered.
* 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;
@@ -296,8 +292,7 @@ function shouldStrandedReset(
export function FolderProvider({ children }: FolderProviderProps) {
const [storedFolders, setFolders] = useState<FolderRecord[]>([]);
// Subdirectories discovered inside mounts, keyed by the parent they were
// listed under. In memory only: a directory is its own record.
// Subdirectories found inside mounts, keyed by parent. In memory only.
const [diskSubfolders, setDiskSubfolders] = useState<
Map<FolderId, FolderRecord[]>
>(() => new Map());
@@ -343,8 +338,7 @@ export function FolderProvider({ children }: FolderProviderProps) {
const refresh = useCallback(async () => {
setLoading(true);
try {
// Two systems of record: the server cache and the browser-owned virtual
// store. The UI sees one list; kind says which rules each row follows.
// Two systems of record behind one list; kind says which rules a row follows.
const [server, virtual, local] = await Promise.all([
folderStorage.getAllFolders(),
virtualFolderStorage.getAllFolders(),
@@ -422,8 +416,7 @@ export function FolderProvider({ children }: FolderProviderProps) {
console.warn("[FolderContext] cache replace failed", cacheErr);
}
if (mountedRef.current) {
// Server-wins applies to server rows only: virtual and local folders
// have no server copy, so a pull says nothing about them.
// Server-wins is for server rows: the other kinds have no server copy.
setFolders((prev) => [
...remote,
...prev.filter((f) => folderKind(f) !== "server"),
@@ -620,19 +613,15 @@ export function FolderProvider({ children }: FolderProviderProps) {
parentFolderId: FolderId | null = currentFolderId,
kind?: FolderKind,
): Promise<FolderRecord> => {
// A child's kind is its parent's, always: one subtree, one system of
// record. Only a root-level create gets to choose, and an unstated
// choice means the server — the one kind every creating surface offers.
// (Virtual is reachable only as a subfolder of an existing virtual
// folder; local is never created here at all.)
// A child's kind is its parent's. Only a root create chooses, defaulting to
// server; virtual is reachable only under a virtual parent, local never here.
const effectiveKind: FolderKind =
parentFolderId !== null
? requireKind(parentFolderId)
: (kind ?? "server");
if (effectiveKind === "local") {
// A subfolder of a mount is a directory: make it on disk and present
// it the way a listing would. Mount roots themselves come from the
// picker, never from here.
// 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");
@@ -721,8 +710,7 @@ export function FolderProvider({ children }: FolderProviderProps) {
async (id: FolderId, name: string) => {
const kind = requireKind(id);
if (kind === "local") {
// The record's name is the directory's name; renaming the directory
// is the filesystem's business, not Stirling's.
// The record's name is the directory's; renaming it is the filesystem's job.
throw new Error("A local folder takes its name from its directory");
}
if (kind === "virtual") {
@@ -747,8 +735,7 @@ export function FolderProvider({ children }: FolderProviderProps) {
const moveFolder = useCallback(
async (id: FolderId, newParentId: FolderId | null) => {
const kind = requireKind(id);
// One subtree, one system of record: a folder can move to the root or
// under a parent of its own kind, never across.
// One subtree, one kind: to the root or under its own kind, never across.
if (newParentId !== null && requireKind(newParentId) !== kind) {
throw new Error("Folders can only move within their own kind");
}
@@ -785,16 +772,13 @@ export function FolderProvider({ children }: FolderProviderProps) {
) => {
const kind = requireKind(id);
if (kind === "local") {
// Nothing persists a local folder's cosmetics yet; its record lives
// with whichever feature mounted it.
// Nothing persists a local folder's cosmetics yet.
throw new Error("Local folders cannot be recoloured yet");
}
if (kind === "virtual") {
// Forward only the fields the picker actually sent: it sends one key
// per interaction, and the store's spread persists an explicit
// undefined — so passing both keys would erase whichever appearance
// field the user did NOT touch. (icon: null means "clear the icon"
// and maps to an explicit undefined deliberately.)
// 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) {
@@ -827,14 +811,12 @@ export function FolderProvider({ children }: FolderProviderProps) {
const kind = requireKind(id);
if (kind === "local") {
if (isDiskFolderId(id)) {
// A subdirectory is the disk's, not a record of ours; the app never
// deletes directories.
// A subdirectory is the disk's; the app never deletes directories.
throw new Error(
"Subfolders of a mounted directory are removed on disk",
);
}
// Removing the mount removes the record and nothing else — the
// directory on disk is the user's, always.
// Removes the record and nothing else; the directory is the user's.
await localFolderStorage.removeFolder(id);
if (mountedRef.current) {
setError(null);
@@ -847,8 +829,7 @@ export function FolderProvider({ children }: FolderProviderProps) {
return [id];
}
if (kind === "virtual") {
// Same shape as the server path below: subtree delete, strand-reset,
// then detach the files that pointed at any removed folder.
// 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) {
@@ -977,8 +958,7 @@ export function FolderProvider({ children }: FolderProviderProps) {
const path = diskFolderPath(id);
if (path === null) return false;
const pathKey = directoryKey(path);
// The mount whose directory contains the path — the deepest one, since
// nested mounts are allowed and the closer root gives the shorter chain.
// 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) {
@@ -83,15 +83,13 @@ export function useLazyThumbnail(
}
/**
* Cache keyed by path + mtime + size, so an unchanged file never renders
* twice and an edited one re-renders. Bounded: a mounted Downloads folder can
* list hundreds of files, and each generation reads the file's FULL bytes off
* disk, so the cache is what makes revisits and re-sorts free.
* Keyed by path + mtime + size, so an unchanged file never renders twice and an
* edited one does. Each generation reads the file's full bytes off disk, so this is
* what makes revisits and re-sorts free.
*/
const diskThumbCache = new Map<string, string>();
// Image thumbnails are data URLs whose size tracks the source image, so the
// cache is bounded by BYTES, not entries — 300 photos would otherwise pin
// gigabytes of strings for the process lifetime.
// 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;
@@ -102,8 +100,7 @@ function cacheDiskThumb(key: string, url: string): void {
diskThumbCacheBytes + url.length > DISK_THUMB_CACHE_MAX_BYTES &&
diskThumbCache.size > 0
) {
// Maps iterate in insertion order; evicting the first entry makes this
// FIFO — crude, but evicted thumbnails simply re-render on revisit.
// 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);
@@ -112,9 +109,7 @@ function cacheDiskThumb(key: string, url: string): void {
diskThumbCacheBytes += url.length;
}
// Reading a file's bytes is the expensive step, so it only happens for types
// the generator can actually render — it branches on MIME (PDF and images)
// and returns nothing for everything else, which must not cost a full read.
// Reading the bytes is the expensive step, so only for types the generator renders.
const THUMBABLE_EXTENSIONS = new Set([
"pdf",
"png",
@@ -6,11 +6,8 @@ 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 — the
* files-page New-folder menu, the empty-state CTA, the sidebar rail — so the
* surfaces present one behavior instead of drifting copies.
*/
/** 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();
@@ -18,18 +15,15 @@ export function useNewFolderFlow() {
const navigate = useNavigate();
const serverFolderBlock = useServerFolderBlock();
// "Add local folder" needs no dialog at all: the native picker is the
// whole interaction, and the directory's name is the folder's name.
// Landing inside the fresh mount is the confirmation.
// No dialog: the picker is the whole interaction and the directory names the
// folder. Landing inside the fresh mount is the confirmation.
const addLocalFolder = useCallback(async () => {
try {
const picked = await pickDirectory();
if (!picked) return;
const record = await folders.mountLocalFolder(picked.path, picked.name);
// The URL is the source of truth for folder selection (the pathname →
// state effect owns currentFolderId). Setting state directly here races
// that effect — it re-runs on the same commit's foldersById change with
// the old pathname and snaps the selection back to root.
// The path owns folder selection. Setting state here races that effect, which
// re-runs on the same commit with the old pathname and snaps back to root.
navigate(`/files/${record.id}`);
} catch (err) {
folders.setError(
@@ -43,11 +37,9 @@ export function useNewFolderFlow() {
}
}, [folders, navigate, t]);
// Single-click "New folder" for surfaces with no menu: the native picker
// where the build can see the disk, the server folder on the web. When the
// server can't take a folder the shortcut is blocked (see
// createFolderHereBlockedReason) rather than acting silently. Inside a
// folder the kind is inherited and none of this applies.
// 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);
@@ -57,8 +49,7 @@ export function useNewFolderFlow() {
void addLocalFolder();
return;
}
// Backstop for the blocked state — the surfaces disable themselves on
// createFolderHereBlockedReason, so a click landing here means stale UI.
// Backstop: surfaces disable themselves, so a click here means stale UI.
if (serverFolderBlock === null) {
openNewFolderDialog(null, "server");
}
@@ -69,9 +60,8 @@ export function useNewFolderFlow() {
serverFolderBlock,
]);
// Why the single-click surfaces should be disabled, or null when they can
// act. Only the web root can block: desktop always has the picker, and
// subfolders inherit their parent's kind.
// 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
@@ -66,10 +66,9 @@ class VirtualFolderStorageService {
}
/**
* Create a virtual folder under the given parent (null = root). The parent,
* when set, must itself be a virtual folder: a virtual row can't hang off a
* server folder, whose lifetime this browser doesn't control — a server-side
* delete would orphan the whole virtual subtree with nothing to notice.
* Create a virtual folder under `parent` (null = root). The parent must itself be
* virtual: hanging one off a server folder means a server-side delete orphans the
* whole subtree with nothing here to notice.
*/
async createFolder(
name: string,
+9 -13
View File
@@ -38,17 +38,14 @@ export const FOLDER_COLOR_PALETTE = [
export type FolderPaletteColor = (typeof FOLDER_COLOR_PALETTE)[number];
/**
* What kind of thing a folder is — three independent features that happen to
* share a shape, not variants of one:
* Three independent features that share a shape, not variants of one:
*
* - `server`: a folder in app storage. Lives in the server's database, synced
* down and cached in IndexedDB; needs login + storage to exist.
* - `virtual`: an organisation-only folder in this browser's IndexedDB. No
* server involvement at all, so it works offline and on installs with
* storage disabled.
* - `local`: a real directory on the machine, mounted read-through — the
* filesystem is the source of truth and Stirling holds no copy of its
* contents, only this record of where it is.
* - `server`: in the server's database, synced down and cached; needs login and
* storage to exist.
* - `virtual`: organisation only, in this browser's IndexedDB; works offline and
* with storage disabled.
* - `local`: a real directory, mounted read-through - the filesystem is the source
* of truth and nothing of its contents is held here.
*/
export type FolderKind = "server" | "virtual" | "local";
@@ -56,9 +53,8 @@ export type FolderKind = "server" | "virtual" | "local";
export interface FolderRecord {
id: FolderId;
/**
* Absent means `server`: kinds arrived after rows already existed in user
* databases and on the server wire, and every one of those is a server
* folder. Read through {@link folderKind} rather than directly.
* Absent means `server`: rows predating kinds are all server folders. Read
* through {@link folderKind} rather than directly.
*/
kind?: FolderKind;
name: string;
@@ -5,18 +5,16 @@ import type { ConnectionMode } from "@app/services/connectionModeService";
import { useServerFolderBlock as useCoreServerFolderBlock } from "@core/hooks/useServerFolderBlock";
/**
* Desktop's server-folder blocker speaks in connection modes. In local mode
* there is no server to hold a folder — "storage isn't enabled" would send
* the user hunting for a setting that doesn't exist, when signing in to
* Stirling Cloud or connecting a self-hosted server IS the fix. The other
* modes have a real server, so the shared account/storage reasons apply.
* Desktop's blocker speaks in connection modes. Local mode has no server at all, so
* "storage isn't enabled" would send the user after a setting that does not exist
* when signing in or connecting a server is the fix. Other modes have a real server,
* so the shared reasons apply.
*/
export function useServerFolderBlock(): string | null {
const { t } = useTranslation();
const coreReason = useCoreServerFolderBlock();
// Seeded from the service's cache so a remount answers correctly on its
// first frame; the effect only matters for the first-ever load and for
// mode switches afterwards.
// Seeded from the cache so a remount answers on its first frame; the effect covers
// the first-ever load and later mode switches.
const [mode, setMode] = useState<ConnectionMode | null>(() =>
connectionModeService.getCachedMode(),
);
@@ -45,26 +45,19 @@ async function isWithinMount(path: string): Promise<boolean> {
);
}
/**
* A directory can hold anything; the page shouldn't drown in it. Everything
* up to the cap lists; past it, the freshest files win — for a Downloads-like
* directory that is also the end the user is looking for.
*/
/** Caps the listing; past the cap the freshest files win, which is what is wanted
* in a Downloads-like directory. */
const LIST_CAP = 500;
/**
* Every stat is a webviewRust round trip, so a big directory's listing cost
* is IPC latency, not disk speed. Overlapping the calls turns N sequential
* hops into N/BATCH; the batch bound keeps a 10k-file directory from opening
* 10k requests at once.
* Every stat is a webview-to-Rust round trip, so listing cost is IPC latency, not
* disk speed. Overlapping turns N hops into N/BATCH, bounded so a 10k-file
* directory does not open 10k requests at once.
*/
const STAT_BATCH = 32;
/**
* Lexical join. The async path.join is itself an IPC round trip, which a
* per-file loop cannot afford; joining a listed directory to a child name it
* itself reported needs no normalization the string can't do.
*/
/** Lexical join: path.join is another IPC round trip, and a directory joined to a
* name it reported itself needs no normalisation a string cannot do. */
function joinPath(directory: string, name: string): string {
const sep = directory.includes("\\") ? "\\" : "/";
const base = directory.endsWith(sep)
@@ -80,8 +73,7 @@ export async function listDirectory(
): Promise<DiskListing | null> {
if (!canListDirectory) return null;
const dirEntries = await readDir(directory);
// Visible entries only: dotfiles are hidden on disk for a reason. One
// level deep — a subdirectory is listed when the user enters it.
// Visible entries, one level deep: a subdirectory lists when the user enters it.
const visible = dirEntries.filter((entry) => !entry.name.startsWith("."));
const directories: DiskDirEntry[] = visible
.filter((entry) => entry.isDirectory)
@@ -121,10 +113,8 @@ export async function listDirectory(
return { files: files.slice(0, LIST_CAP), directories };
}
/**
* Create a subdirectory. Same containment and name rules as a file write;
* the OS refuses an existing name, which is the answer the user wants.
*/
/** Create a subdirectory. Same containment and name rules as a file write; the OS
* refuses an existing name, which is the answer the user wants. */
export async function makeDiskDirectory(
parent: string,
name: string,
@@ -137,10 +127,9 @@ export async function makeDiskDirectory(
}
/**
* The filesystem gives back bytes and a name, never a MIME type — but
* everything downstream branches on File.type (the thumbnail generator's PDF
* path, the workbench's format handling), and an untyped File silently takes
* every "unknown format" branch. Recover the type from the extension.
* The filesystem returns bytes and a name, never a MIME type, and everything
* downstream branches on File.type - an untyped File silently takes every
* "unknown format" path. Recover it from the extension.
*/
const MIME_BY_EXTENSION: Record<string, string> = {
pdf: "application/pdf",
@@ -172,10 +161,9 @@ export async function readDiskFile(entry: DiskFileEntry): Promise<File | null> {
const UNIQUE_NAME_ATTEMPTS = 1000;
/**
* File names come from outside the app's control — zip entries, a server's
* Content-Disposition — and this function holds a filesystem-wide write
* scope. Reduce whatever arrives to a plain basename so a name like
* "..\\evil" cannot steer the write out of the chosen directory.
* Names arrive from outside the app - zip entries, Content-Disposition - and this
* holds a filesystem-wide write scope. Reduce whatever comes to a plain basename so
* a traversal like "..\evil" cannot steer the write out of the chosen directory.
*/
function safeBaseName(name: string): string {
const base = name.split(/[\\/]/).pop() ?? "";
@@ -196,11 +184,9 @@ export async function writeDiskFile(
const base = dot > 0 ? name.slice(0, dot) : name;
const ext = dot > 0 ? name.slice(dot) : "";
const data = new Uint8Array(await bytes.arrayBuffer());
// The directory is the user's: an existing name keeps its file and the
// incomer takes " (n)", the same convention the OS itself uses. The
// exclusive create is what makes that a guarantee rather than a hope: a
// probe-then-write would let a file created in between be overwritten,
// while create-new fails atomically and the loop simply moves on.
// An existing name keeps its file and the incomer takes " (n)", as the OS does.
// The exclusive create is what makes that a guarantee: probe-then-write would let
// a file created in between be overwritten, create-new fails and the loop moves on.
for (let n = 0; n < UNIQUE_NAME_ATTEMPTS; n++) {
const candidate = n === 0 ? name : `${base} (${n})${ext}`;
const path = joinPath(directory, candidate);
@@ -214,10 +200,8 @@ export async function writeDiskFile(
throw new Error(`No free name for ${name} in ${directory}`);
}
/**
* The plugin surfaces OS errors as text. EEXIST is 17; Windows reports
* ERROR_FILE_EXISTS (80) or ERROR_ALREADY_EXISTS (183) depending on the call.
*/
/** The plugin surfaces OS errors as text. EEXIST is 17; Windows reports 80 or 183
* depending on the call. */
function isAlreadyExists(err: unknown): boolean {
const text = err instanceof Error ? err.message : String(err);
return /already exists|file exists|os error (17|80|183)\b/i.test(text);