feat(desktop): Tier B web side — toast actions, Focus Assist gate, folder DnD
- P5-41/35 useTauriToastActions: native rich-toast click → navigate(path) (opens the room), quick reply → mx.sendMessage(roomId, m.text). The desktop bridge routes message notifications (tag=roomId) to show_rich_toast. - P5-56 useTauriFocusAssist + focusAssistActiveAtom: a native focus-assist-changed event drives the atom, OR'd into the existing quiet-hours gate in ClientNonUIFeatures so notifications+sounds suppress during Windows Focus Assist. - P5-48 recursive folder drag-drop: fileEntries.ts (sync webkitGetAsEntry capture → async batched readEntries traversal, path-prefixed names, 500-file cap) wired into useFileDrop, reusing the existing upload pipeline. +3 unit tests. Hooks no-op in the browser. Gates: tsc/eslint/prettier clean, build OK, 559 tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { filesFromEntries } from './fileEntries';
|
||||
|
||||
const fileEntry = (name: string): FileSystemFileEntry =>
|
||||
({
|
||||
isFile: true,
|
||||
isDirectory: false,
|
||||
name,
|
||||
file: (success: (file: File) => void) => {
|
||||
success(new File(['x'], name, { type: 'text/plain' }));
|
||||
},
|
||||
}) as unknown as FileSystemFileEntry;
|
||||
|
||||
/**
|
||||
* A directory whose reader yields its children in several batches (mirroring
|
||||
* Chromium's `readEntries`, which caps each call) and finally an empty batch.
|
||||
*/
|
||||
const dirEntry = (name: string, children: FileSystemEntry[]): FileSystemDirectoryEntry => {
|
||||
const batches = [children.slice(0, 1), children.slice(1), [] as FileSystemEntry[]];
|
||||
return {
|
||||
isFile: false,
|
||||
isDirectory: true,
|
||||
name,
|
||||
createReader: () => {
|
||||
let call = 0;
|
||||
return {
|
||||
readEntries: (success: (entries: FileSystemEntry[]) => void) => {
|
||||
const batch = batches[call] ?? [];
|
||||
call += 1;
|
||||
success(batch);
|
||||
},
|
||||
} as unknown as FileSystemDirectoryReader;
|
||||
},
|
||||
} as unknown as FileSystemDirectoryEntry;
|
||||
};
|
||||
|
||||
test('filesFromEntries flattens nested folders and prefixes relative paths', async () => {
|
||||
const entries: FileSystemEntry[] = [
|
||||
fileEntry('top.txt'),
|
||||
dirEntry('photos', [
|
||||
fileEntry('a.jpg'),
|
||||
dirEntry('2024', [fileEntry('b.jpg'), fileEntry('c.jpg')]),
|
||||
]),
|
||||
];
|
||||
|
||||
const files = await filesFromEntries(entries);
|
||||
const names = files.map((f) => f.name).sort();
|
||||
|
||||
assert.deepEqual(names, ['photos/2024/b.jpg', 'photos/2024/c.jpg', 'photos/a.jpg', 'top.txt']);
|
||||
});
|
||||
|
||||
test('filesFromEntries reads directory entries in batches until empty', async () => {
|
||||
const entries: FileSystemEntry[] = [
|
||||
dirEntry('docs', [fileEntry('one.txt'), fileEntry('two.txt')]),
|
||||
];
|
||||
|
||||
const files = await filesFromEntries(entries);
|
||||
assert.equal(files.length, 2);
|
||||
});
|
||||
|
||||
test('filesFromEntries respects the maxFiles cap', async () => {
|
||||
const entries: FileSystemEntry[] = [
|
||||
dirEntry('many', [fileEntry('a.txt'), fileEntry('b.txt')]),
|
||||
fileEntry('c.txt'),
|
||||
];
|
||||
|
||||
const files = await filesFromEntries(entries, 2);
|
||||
assert.equal(files.length, 2);
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { getDataTransferFiles, renameFile } from './dom';
|
||||
|
||||
// Guard against pathological drops (deeply nested / huge trees) that could
|
||||
// otherwise queue thousands of uploads and freeze the composer.
|
||||
export const MAX_DROPPED_FILES = 500;
|
||||
|
||||
/**
|
||||
* Synchronously collect the `FileSystemEntry` objects for every item in a
|
||||
* drop's `DataTransfer`.
|
||||
*
|
||||
* This MUST be called synchronously inside the drop event handler: the
|
||||
* `DataTransferItemList` is emptied once the handler returns, so calling
|
||||
* `webkitGetAsEntry()` after an `await` yields `null`. Capture the entries
|
||||
* first, then traverse them asynchronously with {@link filesFromEntries}.
|
||||
*
|
||||
* Returns an empty array when `webkitGetAsEntry` is unavailable (non-Chromium
|
||||
* browsers), signalling the caller to fall back to the flat file list.
|
||||
*/
|
||||
export const entriesFromDataTransfer = (dataTransfer: DataTransfer): FileSystemEntry[] => {
|
||||
const entries: FileSystemEntry[] = [];
|
||||
const { items } = dataTransfer;
|
||||
if (!items) return entries;
|
||||
|
||||
for (let i = 0; i < items.length; i += 1) {
|
||||
const item = items[i];
|
||||
if (item && item.kind === 'file' && typeof item.webkitGetAsEntry === 'function') {
|
||||
const entry = item.webkitGetAsEntry();
|
||||
if (entry) entries.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
};
|
||||
|
||||
const fileFromFileEntry = (entry: FileSystemFileEntry): Promise<File> =>
|
||||
new Promise((resolve, reject) => {
|
||||
entry.file(resolve, reject);
|
||||
});
|
||||
|
||||
/**
|
||||
* Read every entry from a directory reader.
|
||||
*
|
||||
* `readEntries` returns results in BATCHES (Chromium yields at most ~100 per
|
||||
* call), so it must be called repeatedly until it resolves with an empty array.
|
||||
*/
|
||||
const readAllDirectoryEntries = (reader: FileSystemDirectoryReader): Promise<FileSystemEntry[]> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const all: FileSystemEntry[] = [];
|
||||
const readBatch = () => {
|
||||
reader.readEntries((batch) => {
|
||||
if (batch.length === 0) {
|
||||
resolve(all);
|
||||
return;
|
||||
}
|
||||
all.push(...batch);
|
||||
readBatch();
|
||||
}, reject);
|
||||
};
|
||||
readBatch();
|
||||
});
|
||||
|
||||
/**
|
||||
* Recursively walk `FileSystemEntry` objects (as produced by
|
||||
* {@link entriesFromDataTransfer}) and resolve them into a flat `File[]`,
|
||||
* descending into every nested directory.
|
||||
*
|
||||
* Nested files keep their relative folder path as a name prefix (e.g.
|
||||
* `photos/2024/pic.jpg`) so uploads remain distinguishable. Traversal stops
|
||||
* once `maxFiles` files have been collected.
|
||||
*/
|
||||
export const filesFromEntries = async (
|
||||
entries: FileSystemEntry[],
|
||||
maxFiles: number = MAX_DROPPED_FILES,
|
||||
): Promise<File[]> => {
|
||||
const files: File[] = [];
|
||||
|
||||
const walk = async (entry: FileSystemEntry, prefix: string): Promise<void> => {
|
||||
if (files.length >= maxFiles) return;
|
||||
|
||||
if (entry.isFile) {
|
||||
const file = await fileFromFileEntry(entry as FileSystemFileEntry);
|
||||
if (files.length >= maxFiles) return;
|
||||
files.push(prefix ? renameFile(file, `${prefix}${file.name}`) : file);
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry.isDirectory) {
|
||||
const reader = (entry as FileSystemDirectoryEntry).createReader();
|
||||
const childEntries = await readAllDirectoryEntries(reader);
|
||||
const childPrefix = `${prefix}${entry.name}/`;
|
||||
for (const child of childEntries) {
|
||||
if (files.length >= maxFiles) break;
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await walk(child, childPrefix);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const entry of entries) {
|
||||
if (files.length >= maxFiles) break;
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await walk(entry, '');
|
||||
}
|
||||
|
||||
return files;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extract dropped files, descending into any dropped folders.
|
||||
*
|
||||
* Captures the `FileSystemEntry` list synchronously (required — see
|
||||
* {@link entriesFromDataTransfer}) then traverses it asynchronously. Falls back
|
||||
* to the flat `dataTransfer.files` list when the directory API is unavailable
|
||||
* (non-Chromium) or when no entries are exposed.
|
||||
*/
|
||||
export const collectDroppedFiles = (dataTransfer: DataTransfer): Promise<File[] | undefined> => {
|
||||
const entries = entriesFromDataTransfer(dataTransfer);
|
||||
if (entries.length === 0) {
|
||||
return Promise.resolve(getDataTransferFiles(dataTransfer));
|
||||
}
|
||||
return filesFromEntries(entries).then((files) => (files.length > 0 ? files : undefined));
|
||||
};
|
||||
Reference in New Issue
Block a user