docs(folders): one line each

Every multi-line aside cut to its first sentence. What went was the second and
third sentences qualifying it.
This commit is contained in:
Reece
2026-09-01 16:00:26 +01:00
parent 056de6d9ee
commit b6139d1cd0
24 changed files with 83 additions and 237 deletions
@@ -52,10 +52,8 @@ import type { FilesPageSortMode } from "@app/contexts/FilesPageContext";
import { OpenInNewWindowMenuItem } from "@app/components/filesPage/OpenInNewWindowMenuItem";
/**
* The origin badge a folder wears, mirroring the one its files would: a
* server folder is Cloud, a virtual folder is Local (this browser), a
* mounted folder is On disk. Tooltips are folder-phrased — the badge's own
* defaults describe files.
* The origin badge a folder wears, mirroring the one its files would: a server folder
* is Cloud, a virtual folder is Local (this browser), a mounted folder is On disk.
*/
function useFolderOriginBadge(folder: FolderRecord): {
origin: "cloud" | "local";
@@ -1650,13 +1648,7 @@ function FileRow({
// Re-export root constant for caller convenience
export { ROOT_FOLDER_ID };
/**
* A file listed straight off a mounted directory. Nothing behind it is
* stored: no stub, no selection, no move/rename/delete — the disk owns the
* file, and the one thing Stirling adds is "Add to workspace", which loads
* the bytes in. Visually it is a normal file card wearing an "On disk"
* origin badge, because to the user it is simply a file.
*/
/** A file listed straight off a mounted directory. */
function DiskFileCard({
entry,
onOpen,
@@ -1093,8 +1093,7 @@ export default function FileManagerView() {
);
// 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.
// disabled item's caption.
const serverFolderDisabledReason = useServerFolderBlock() ?? undefined;
const { addLocalFolder, createFolderHere, createFolderHereBlockedReason } =
@@ -1123,8 +1122,7 @@ export default function FileManagerView() {
) {
return serverFolderDisabledReason;
}
// 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.
// The web root creates on the server or not at all.
if (
folders.currentFolderId === null &&
!canPickDirectory &&
@@ -11,10 +11,7 @@ interface FileOriginBadgeProps {
origin: FileOrigin;
/** Compact (icon-only) vs full (icon + text). */
compact?: boolean;
/**
* Override the hover text. The defaults are phrased for files; a folder
* wearing the same badge needs its own wording.
*/
/** Override the hover text. */
tooltip?: string;
}
@@ -328,9 +328,8 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
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. Server files
// stay put - 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.
const { written, failedCount } = await writeIntoMount(
targetFolder?.directory,
localOnly.map((stub) => ({
@@ -374,8 +373,7 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
}
if (targetKind === "virtual") {
// Browser-owned, so membership is too: local files just point folderId at
// it. Server files stay out or the next sync snaps them back.
// Browser-owned, so membership is too: local files just point folderId at it.
if (cloudFiles.length > 0) {
folders.setError(
t(
@@ -470,11 +468,9 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
}
}
// 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). Without this, a file placed in a virtual folder could never
// be taken out of it.
// 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)
@@ -674,10 +670,9 @@ 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. Remove directly.
// 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
@@ -97,11 +97,7 @@ interface FolderContextValue {
ok: boolean;
reason?: "endpoint-missing" | "network" | "server" | "client";
}>;
/**
* 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.
*/
/** Create a folder. */
createFolder: (
name: string,
parentFolderId?: FolderId | null,
@@ -117,16 +113,9 @@ interface FolderContextValue {
appearance: { color?: string; icon?: string | null },
) => Promise<FolderRecord | null>;
deleteFolder: (id: FolderId) => Promise<FolderId[]>;
/**
* Mount a directory as a local folder, idempotent per directory. Unmounting goes
* through {@link deleteFolder}; neither touches the directory itself.
*/
/** Mount a directory as a local folder, idempotent per directory. */
mountLocalFolder: (directory: string, name: string) => Promise<FolderRecord>;
/**
* 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.
*/
/** Subdirectories a mount listing found under `parentId`. */
registerDiskSubfolders: (parentId: FolderId, records: FolderRecord[]) => void;
/**
* Rebuild the records behind a disk-subfolder id, for a link arriving before any
@@ -613,8 +602,7 @@ export function FolderProvider({ children }: FolderProviderProps) {
parentFolderId: FolderId | null = currentFolderId,
kind?: FolderKind,
): Promise<FolderRecord> => {
// 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.
// A child's kind is its parent's.
const effectiveKind: FolderKind =
parentFolderId !== null
? requireKind(parentFolderId)
@@ -274,10 +274,9 @@ interface AddFileOptions {
* 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. See StirlingFileStub.folderId
* for what the value means per storage state.
* 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;
}
@@ -83,9 +83,8 @@ export function useLazyThumbnail(
}
/**
* 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.
* Keyed by path + mtime + size, so an unchanged file never renders twice and an edited
* one does.
*/
const diskThumbCache = new Map<string, string>();
// Bounded by bytes, not entries: image thumbnails are data URLs that track the
@@ -127,11 +126,9 @@ function canEverThumbnail(name: string): boolean {
}
/**
* 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.
* Returns undefined while pending, unsupported, or too large (placeholder
* icon stays).
* 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;
@@ -172,7 +169,6 @@ export function useDiskThumbnail(entry: {
cancelled = true;
};
// The key encodes every field of `entry` this effect reads.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
return thumb;
@@ -15,15 +15,13 @@ export function useNewFolderFlow() {
const navigate = useNavigate();
const serverFolderBlock = useServerFolderBlock();
// No dialog: the picker is the whole interaction and the directory names the
// folder. Landing inside the fresh mount is the confirmation.
// 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 that effect, which
// re-runs on the same commit with the old pathname and snaps back to root.
// The path owns folder selection.
navigate(`/files/${record.id}`);
} catch (err) {
folders.setError(
@@ -60,8 +58,7 @@ export function useNewFolderFlow() {
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.
// Why the single-click surfaces are disabled, or null.
const createFolderHereBlockedReason =
folders.currentFolderId === null && !canPickDirectory
? serverFolderBlock
@@ -3,13 +3,7 @@ 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. The
* answer is what renders on the disabled option/button, so it must name the
* user's actual next step. This core version speaks web's language — sign in,
* or storage is off on the server — and the desktop build shadows it: there
* the honest blocker in local mode is having no server connected at all.
*/
/** 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();
+2 -4
View File
@@ -792,10 +792,8 @@ const MyFilesSidebarOverrides = forwardRef<HTMLDivElement, FileSidebarProps>(
[addFiles, filesPage, folders.currentFolderId],
);
// Kind-aware: only a server folder's subfolder needs the server, and a
// mounted directory takes no subfolders from here at all. At the root
// the shortcut's own availability rules apply (picker on desktop,
// server folder on web, blocked when the server can't take one).
// 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;
@@ -1,11 +1,4 @@
/**
* Picking a directory on the machine, as a real filesystem path.
*
* Only an environment that can see the filesystem can offer this — a browser
* deliberately cannot reveal paths (the File System Access API deals in
* handles, not locations), so core reports the capability absent and the
* desktop build shadows this module with the Tauri dialog.
*/
/** Picking a directory on the machine, as a real filesystem path. */
export interface PickedDirectory {
/** Absolute path, as the platform writes it. */
@@ -121,9 +121,8 @@ 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. Read best-effort: with the store
// unreadable, behave exactly as before the guard existed.
// 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()
@@ -18,11 +18,8 @@ import {
} 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.
* Virtual folders live in their own store (virtualFolderStorage); local
* folders are records of a directory, not cache entries. Refusing loudly here
* is what keeps a mis-routed mutation a bug report instead of data loss.
* 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") {
@@ -402,8 +402,6 @@ describe("IndexedDB migration (FILES store)", () => {
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.
// The cure is the shipped version bump: v11 declares both, so the normal
// upgrade path (which only adds what's absent) completes the schema.
await new Promise<void>((resolve, reject) => {
const req = indexedDB.open(DB_NAME, 10);
req.onupgradeneeded = () => {
@@ -465,10 +465,8 @@ class IndexedDBManager {
export const DATABASE_CONFIGS = {
FILES: {
name: "stirling-pdf-files",
// v10 existed briefly with only one of the two browser-folder stores;
// v11 declares both, so every v10 profile upgrades to a full schema.
// Never add a store under an already-opened version number — the upgrade
// only fires on a version change, so late additions are unreachable.
// 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: [
{
@@ -496,20 +494,15 @@ export const DATABASE_CONFIGS = {
{ name: "createdAt", keyPath: "createdAt", unique: false },
],
},
// Folders mounted from a directory on the machine (kind "local"). Flat
// by construction — a mount has no parent, and its subdirectories are
// the filesystem's business. Same lifecycle reasoning as
// virtual_folders: browser-owned, so never in the server-synced cache.
// Folders mounted from a directory on the machine (kind "local").
{
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.
// NOT named smart_folders/folder_members/folder_run_states — the upgrade
// cleanup above deletes stores by those names.
// 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",
@@ -1,9 +1,4 @@
/**
* Reading a mounted folder's contents straight off the disk. Read-through: the
* listing is the directory, taken fresh each look, with nothing ingested to show it.
* Only an environment that can see the filesystem can do this, so core reports the
* capability absent and the desktop build shadows this module.
*/
/** Reading a mounted folder's contents straight off the disk. */
/** One file inside a mounted directory, as the file manager lists it. */
export interface DiskFileEntry {
@@ -30,9 +25,8 @@ export interface DiskListing {
export const canListDirectory = false;
/**
* The regular files and subdirectories directly inside `directory` — one
* level, never recursive; a subdirectory is listed only when entered. Null
* when unsupported.
* 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,
@@ -40,10 +34,7 @@ export async function listDirectory(
return null;
}
/**
* Create a subdirectory inside a mounted directory. Returns its path, or
* null when unsupported.
*/
/** Create a subdirectory inside a mounted directory. */
export async function makeDiskDirectory(
_parent: string,
_name: string,
@@ -58,12 +49,7 @@ export async function readDiskFile(
return null;
}
/**
* Write a file into a mounted directory, under a name that never clobbers an
* existing one. Moving an app file INTO a mount means putting it on the
* filesystem — only a build that can see the filesystem can. Returns the
* name the file landed under, or null when unsupported.
*/
/** Write a file into a mounted directory, under a name that never clobbers an existing one. */
export async function writeDiskFile(
_directory: string,
_name: string,
@@ -11,10 +11,8 @@ import {
} 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. The filesystem is
* happy to spell one directory many ways, so the dedupe has to see through
* the spelling.
* 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(() => {
@@ -1,11 +1,6 @@
/**
* The record of directories mounted into the file manager (kind "local"): a pointer
* at a directory, nothing more. The directory owns its name, contents and lifetime,
* so the record holds only where it is and how to show it, and removing a mount
* removes the record and nothing else.
*
* Mounts are flat by construction - no parent, and a directory's subdirectories are
* the filesystem's business rather than a hierarchy for this store to model.
* The record of directories mounted into the file manager (kind "local"): a pointer at
* a directory, nothing more.
*/
import {
@@ -20,14 +15,7 @@ import {
DATABASE_CONFIGS,
} from "@app/services/indexedDBManager";
/**
* One directory, one key — regardless of how the picker spelled the path.
* Lexical only: separators unified, repeats collapsed (UNC's leading pair
* kept), trailing separator stripped, and case folded for Windows-style
* paths (drive letter or UNC), where the filesystem ignores case but a
* string compare doesn't. Elsewhere case stays significant. Symlinks and 8.3
* short names are beyond a string's power to resolve and stay distinct.
*/
/** 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("//");
@@ -71,10 +59,8 @@ class LocalFolderStorageService {
}
/**
* 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. Nesting is fine -
* a mount lists only its own files, so a subdirectory needs its own mount to be
* reachable.
* 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);
@@ -8,11 +8,8 @@ export interface MountWriteItem {
/**
* 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. Failures are counted, never thrown: the
* caller owes one banner for the batch rather than an abort at the first bad file.
* `written[i]` says which items verifiably landed and are safe to retire from app
* storage.
* 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,
@@ -1,13 +1,6 @@
/**
* The system of record for kind "virtual" folders - rows this store owns rather
* than caches. {@link folderStorage} is wiped and rewritten on every sync because
* the server is authoritative there; a virtual folder exists only in this browser,
* with nothing to restore it from. They organise files where there is no login, no
* server storage, or no network.
*
* No server to enforce them means the invariants live here: no reparenting a folder
* under its own subtree, and a bounded depth, with the limits mirroring
* FolderService so a hierarchy does not behave differently for being virtual.
* The system of record for kind "virtual" folders - rows this store owns rather than
* caches.
*/
import {
@@ -65,11 +58,7 @@ class VirtualFolderStorageService {
});
}
/**
* 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.
*/
/** Create a virtual folder under `parent` (null = root). */
async createFolder(
name: string,
parentFolderId: FolderId | null,
@@ -194,12 +183,7 @@ class VirtualFolderStorageService {
});
}
/**
* Walk from `startId` to the root, returning the ids seen. Throws when the
* chain is missing a link (the parent must exist and be virtual), already
* cyclic (defensive — a bug or hand-edited DB, not a reachable state), or
* too deep to accept another child.
*/
/** 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.
+4 -17
View File
@@ -38,24 +38,15 @@ export const FOLDER_COLOR_PALETTE = [
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.
* - `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.
* 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;
/**
* Absent means `server`: rows predating kinds are all server folders. Read
* through {@link folderKind} rather than directly.
*/
/** Absent means `server`: rows predating kinds are all server folders. */
kind?: FolderKind;
name: string;
parentFolderId: FolderId | null;
@@ -106,11 +97,7 @@ export function parseFolderId(value: unknown): FolderId {
return value as FolderId;
}
/**
* Subdirectories of a mounted folder are not stored anywhere — the directory
* is the record. Their ids encode the path, so a `/files/<id>` link survives
* a reload and the record can be rebuilt from the id alone.
*/
/** 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 {
@@ -4,12 +4,7 @@ import { connectionModeService } from "@app/services/connectionModeService";
import type { ConnectionMode } from "@app/services/connectionModeService";
import { useServerFolderBlock as useCoreServerFolderBlock } from "@core/hooks/useServerFolderBlock";
/**
* 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.
*/
/** Desktop's blocker speaks in connection modes. */
export function useServerFolderBlock(): string | null {
const { t } = useTranslation();
const coreReason = useCoreServerFolderBlock();
@@ -31,11 +26,10 @@ export function useServerFolderBlock(): string | null {
unsubscribe();
};
}, []);
// While the mode is unknown (first-ever load), fail closed with the same
// message: in any mode where the item would be blocked, signing in or
// connecting a server IS the way out — unlike the core reasons, which in
// local mode point at a storage setting that doesn't exist. A blocked
// frame beats an enabled item pointing at a server that isn't there.
// While the mode is unknown (first-ever load), fail closed with the same message: in
// any mode where the item would be blocked, signing in or connecting a server IS the
// way out — unlike the core reasons, which in local mode point at a storage setting
// that doesn't exist.
if (mode === "local" || mode === null) {
return t(
"filesPage.serverFolderNeedsConnection",
@@ -71,11 +71,7 @@ export class ConnectionModeService {
return config.mode;
}
/**
* The mode already in memory, or null before the first load. For render
* paths that must answer synchronously — after boot the config is a plain
* field, and a first paint shouldn't wait on (or guess through) a promise.
*/
/** The mode already in memory, or null before the first load. */
getCachedMode(): ConnectionMode | null {
return this.currentConfig?.mode ?? null;
}
@@ -1,8 +1,4 @@
/**
* Desktop read-through for mounted local folders, over the Tauri filesystem
* plugin. The listing is taken fresh from the directory on every call —
* nothing is copied or ingested to produce it.
*/
/** Desktop read-through for mounted local folders, over the Tauri filesystem plugin. */
import { isTauri } from "@tauri-apps/api/core";
import {
@@ -24,13 +20,8 @@ import type {
export type { DiskDirEntry, DiskFileEntry, DiskListing };
/**
* Containment: these reads and writes run under a filesystem-wide Tauri capability,
* but the contract here is mounted directories only, so any path outside one is
* refused. The capability layer is the real trust boundary; this keeps a bad path
* inert.
*
* The mount store is read fresh every call - a handful of rows against the file
* bytes about to cross the IPC bridge - so a removed mount is refused at once.
* Containment: these reads and writes run under a filesystem-wide Tauri capability, but
* the contract here is mounted directories only, so any path outside one is refused.
*/
async function isWithinMount(path: string): Promise<boolean> {
const pathKey = directoryKey(path);
@@ -50,9 +41,8 @@ async function isWithinMount(path: string): Promise<boolean> {
const LIST_CAP = 500;
/**
* 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.
* Every stat is a webview-to-Rust round trip, so listing cost is IPC latency, not disk
* speed.
*/
const STAT_BATCH = 32;
@@ -113,8 +103,7 @@ 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. */
export async function makeDiskDirectory(
parent: string,
name: string,
@@ -127,9 +116,8 @@ export async function makeDiskDirectory(
}
/**
* 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.
* 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.
*/
const MIME_BY_EXTENSION: Record<string, string> = {
pdf: "application/pdf",
@@ -161,9 +149,8 @@ export async function readDiskFile(entry: DiskFileEntry): Promise<File | null> {
const UNIQUE_NAME_ATTEMPTS = 1000;
/**
* 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.
* Names arrive from outside the app - zip entries, Content-Disposition - and this holds
* a filesystem-wide write scope.
*/
function safeBaseName(name: string): string {
const base = name.split(/[\\/]/).pop() ?? "";
@@ -185,8 +172,6 @@ export async function writeDiskFile(
const ext = dot > 0 ? name.slice(dot) : "";
const data = new Uint8Array(await bytes.arrayBuffer());
// 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);
@@ -200,8 +185,7 @@ 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 80 or 183
* depending on the call. */
/** The plugin surfaces OS errors as text. */
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);