diff --git a/docs/guide/extension-ui.md b/docs/guide/extension-ui.md index 0691727c..c8929242 100644 --- a/docs/guide/extension-ui.md +++ b/docs/guide/extension-ui.md @@ -2125,6 +2125,101 @@ installed with it, and it waits as above until what it requires is there. Before anything is installed, Discover says what one that comes with the portal requires ("Needs pithagoras-docker"). +### Showing a file tree in Files + +[Files](/guide/files) can show a file tree of your extension — a cloud drive, a +wiki, a bucket — as a root of its own beside the chat's folder, with the same +file view: browsing, reading, downloading, and saving where you let it. Files +(`pithagoras-files`) is an extension that [gives others something to build +on](#building-on-another-extension), so yours names it in its package.json and +registers a source with what `portal.extensions.use` gives it: + +```json +"pithagoras": { "server": "./server.js", "requires": { "pithagoras-files": "^0.2.0" } } +``` + +(`"optional"` in place of `"requires"` if your extension is useful without +Files: `use` then gives nothing when Files is not there.) + +```js +export default function (portal) { + const files = portal.extensions.use("pithagoras-files"); + files.sources.register({ + id: "cloud", + label: "Nextcloud", + // Which chats and people see it: asked each time Files lists its sources. + visible: ({ chatId, owner, person }) => owner?.kind === "agent" && person?.role === "primary", + // The folder at `path` ("" is the root), in the source's own paths. + list: async (path) => (await client.propfind(path)).map((f) => ({ name: f.name, type: f.isDir ? "dir" : "file", size: f.size, mtime: f.modified })), + // Text is shown; bytes are only offered as a download. + read: async (path) => { + const body = await client.get(path); + return isText(body) ? { content: body.toString("utf8"), mtime: body.modified } : { bytes: body, mtime: body.modified }; + }, + // Optional. A link to open or download with (http or https); without it the file is sent through Files. + url: (path) => `https://cloud.example/index.php/apps/files/?dir=${encodeURIComponent(path)}`, + // Optional. Without it the source is read-only. Throw an error with code "conflict" if `expected` (the mtime it was read with) is stale. + write: async (path, content, { expected }) => ({ mtime: await client.put(path, content, expected) }), + }); + // A server half's stop function: a source is not taken away by itself when your extension is switched off. + return () => files.sources.unregister("cloud"); +} +``` + +- **`id`** is letters, digits, `.`, `_` and `-` (64 at most, starting with a + letter or digit); no two sources share one, and registering the same id again + from your extension replaces the earlier one. **`label`** is the tab's name: + it is shown as you give it, so translate it yourself if you want it translated. + `register` throws a `TypeError` for a source that is not shaped as above, or + whose id another extension has; `unregister(id)` takes away your own and does + nothing for another's. +- **Paths are the source's own**: `""` is the root, and `a/b` is a file or folder + in `a`. The page builds them from names you listed, so Files refuses what it + never sends (a `..` or `.` part, an empty part such as `a//b`, a leading `/`, a + `\`, a NUL, more than 4,096 characters) before you are called. It does not + resolve or clean the rest: a source over a file system or a URL still joins + and checks the path against its own base folder, and a source that keeps each + chat's files apart (by `chatId`) must put the path under that chat's folder + itself, not take it from `path` alone. +- **`list`** gives `{ name, type: "file" | "dir", size?, mtime? }` for each thing + directly in the folder. Names with a `/` or `\`, `.` or `..` or no name, and a type other than + these, are left out; Files sorts them (folders first) and shows 2,000 at most. +- **`read`** gives `{ content, mtime? }` (text, shown up to 1 MB, over that it is + offered as a download) or `{ bytes, mtime? }` (offered as a download). Give + `mtime` when you can: a save is checked against it. Files does not watch a + source's open file, as it does the chat folder's (that would fetch the whole file + again every few seconds); Refresh reads it again. +- **`write`** present makes the source writable: the file view lets the person + edit text and save it. It is called with the file's new text and, beside the + context, `expected` when the person read the file with an `mtime`. It may return + `{ mtime }` for the file as saved. Creating, renaming, deleting and uploading + are not part of it; the source is only saved to. +- **Every call is given a context** as its last argument: `chatId`, `owner` (whose + chat it is: an agent's or a project's, as `chats.owner` has it, without its `path`) and `person` (who is at the page, as `http.person`). It is how a source + follows the chat's owner. `visible` gets the same, and a source it says no to is + not listed and its routes answer "no such source". A `visible` that throws or + does not answer in 5 seconds counts as no. The context never has the chat's + folder, nor where the owner's folder is on the machine, and the chat's folder + is never given a source's path. +- **Errors.** A call that throws is shown in the source's own tab. An error with a + `code` of `missing`, `conflict`, `invalid`, `exists`, `too_large` or `failed` + keeps the meaning it has for the chat's folder (404, 409, 400, 409, 413, 500) + and its message is shown as it is; any other error is shown as + "Label: message". A call that has not answered in 30 seconds is cut off and + reported as that source not answering. Nothing a source does holds up the chat's + folder. +- **Download.** With `url`, the download buttons go to that address (Files + redirects to it; an address that is not `http:` or `https:` is refused). + Without it Files sends what `read` gives as a file. There is no archive of a + source's folder. +- **Pictures** in a source are offered as downloads: Files shows pictures through + the portal's picture route for the chat's folder. +- This is written for a server half in the portal's process. A half in a + [process of its own](#a-server-half-in-a-process-of-its-own) gets Files' + functions as promises and its callbacks are called across, but a source is an + object made of callbacks, and registering one from there is not something the + portal tests. + ### Extensions it installs An extension can install a package it wrote itself, load it again after a diff --git a/docs/guide/files.md b/docs/guide/files.md index 16d86fdc..e490021e 100644 --- a/docs/guide/files.md +++ b/docs/guide/files.md @@ -52,6 +52,21 @@ box and its conversation use as well. place once all of it has arrived, so an upload cut short leaves nothing behind. Up to 2 GB a file. Files dropped on the message box go to the chat's folder as well — see [Pictures and files](/guide/sessions#pictures-and-files). +- **Upload a folder** with the folder-arrow button next to the upload arrow, or + by dropping a folder on the list. Its whole tree goes in the folder you are + in: the folders are made first, then each file goes into its own, and an + empty folder is made too (a picked folder has no way to say that it had one, + so only a dropped one keeps its empty folders). A folder whose name is taken + gets a number — `photos (2)` — and its files go into that one: folders are + never merged. The counter at the top counts every file of the tree. A file + that fails does not stop the others; the reasons are listed above the folder + (the first five, and how many more), each with the path of the file or folder + it is about. What cannot be uploaded is told apart: a name that is not a path + inside the folder, and something the browser could not read (it was gone + since the drop, or not allowed). If you go to another tab while an upload is + running, it finishes in the background, and its reasons are there when you are + back in the chat's folder. A browser that cannot read dropped + folders uploads the plain files of a drop, as before. - **Rename** a file or a folder with the pencil on its row. Enter keeps the new name, Escape leaves it. It is a new name in the same folder, and never replaces something that is already there. @@ -108,6 +123,44 @@ is kept there as a revision, marked as an outside change, and a page that has the document open is told of it; deleting or renaming such a file is dealt with as [Documents](/guide/documents#a-file-that-is-deleted-or-renamed) describes. +## Other file sources + +An extension can show a file tree of its own in Files — a cloud drive, a wiki, +a bucket — as a second place to look beside the chat's folder. When there is one, +a row of buttons appears under the ones at the top, the one for what you are +looking at pressed: the chat's folder first, +then each source by the name its extension gave it. Without one, nothing is +different. + +- **Browse and read** a source as you do the folder: folders and files, the path + at the top to go back, text in the editor, markdown as a page, and the download + button on a row or in an open file. A file that is not text, or is over 1 MB, + is not shown; download it. An open file is not looked at every few seconds as + the folder's is (that would fetch the whole file from the source each time); + **Refresh** reads it again. +- **Read-only** unless its extension lets Files save into it. A read-only source + says so on an open file ("Read-only"), has no **Save**, and its text cannot be + typed into. A source that can be saved to is edited like any file here; the + save is checked against the time the file was read at, as in the folder, when + the source gives one. Nothing is made, uploaded, renamed or deleted in a source + from here, whatever it can save, and a drop on it is ignored. +- **Each one on its own.** A source that is slow shows "Loading…" in its own tab + while the chat's folder, one click away, is there at once; one that fails shows + its reason in its own tab and the folder is not affected. The tabs are read + when Files opens and again with **Refresh**, so a source whose extension started + later shows after one. +- **Not mixed.** A source's paths are its own. What the agent reads and writes is + in the chat's folder, so **Follow** brings you back to it, and a file another + panel opens in Files (from Git, say) is opened in the folder. Nothing of the + folder is sent to a source, and no source is given the folder's path. +- **Whose.** A source is offered per chat: its extension is told which chat is + open, whose chat it is (the agent or project) and who is at the page, and can + show it to some chats and not others, as the folder is the chat's own. + +Which sources there are depends on the extensions installed: see +[Showing a file tree in Files](/guide/extension-ui#showing-a-file-tree-in-files) +for how an extension adds one. + ## Watching the agent Files follows the agent. When it reads a file, or writes or edits one, that file diff --git a/extensions/files/package.json b/extensions/files/package.json index 9b3803cc..bbf45a29 100644 --- a/extensions/files/package.json +++ b/extensions/files/package.json @@ -1,6 +1,6 @@ { "name": "pithagoras-files", - "version": "0.1.0", + "version": "0.2.0", "description": "The files of a Pithagoras chat's folder beside the conversation and in voice mode: browse, read and edit them, follow what the agent reads and writes, upload, rename, delete and download", "type": "module", "keywords": ["pi-package"], diff --git a/extensions/files/src/FilesPanel.tsx b/extensions/files/src/FilesPanel.tsx index 80db2ed2..5b7c8189 100644 --- a/extensions/files/src/FilesPanel.tsx +++ b/extensions/files/src/FilesPanel.tsx @@ -1,4 +1,4 @@ -import { memo, useCallback, useEffect, useRef, useState } from "react"; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { LuArrowLeft, LuBookOpen, @@ -12,6 +12,7 @@ import { LuFileText, LuFolder, LuFolderPlus, + LuFolderUp, LuLink, LuLocateFixed, LuMapPin, @@ -21,11 +22,12 @@ import { LuTrash2, LuUpload, } from "react-icons/lu"; -import { api, type FileEntry, type Unsaved } from "./files-api"; +import { api, folderReader, listSources, sourceReader, type FileEntry, type SourceInfo, type Unsaved } from "./files-api"; import type { FileActivity } from "./activity"; import { flushFileDraft, keepFileDraft, readFileDraft } from "./drafts"; import { bytesLabel, isEnter, isEscape, within } from "./helpers"; import { folderPictures } from "./pictures"; +import { countFiles, droppedEntries, treeOfEntries, treeOfPaths, uploadTree, type Entry, type Tree } from "./tree"; import { deleteAsking, unsavedNotes } from "./unsaved"; import { confirmDialog, isApiError, local, Markdown, t, tp, useLanguage } from "./portal"; @@ -149,7 +151,32 @@ export const FilesPanel = memo(function FilesPanel({ // Files on their way up, and whether some are being dragged over the list. const [uploading, setUploading] = useState(0); const [dropping, setDropping] = useState(false); + // A dropped folder being looked through, before the number of files in it is known. + const [reading, setReading] = useState(false); + // The tree being looked at: the chat's folder (null) or a source another extension gave (its id), and the sources there are. + const [root, setRoot] = useState(null); + const [sources, setSources] = useState([]); + const rootRef = useRef(root); + rootRef.current = root; + // Counts the changes of tree: work that started in one and ends after a change must not put its result into the other. + const rootGen = useRef(0); + // What the chat's folder has to say about work that ended while a source was shown (a failed upload, rename, delete), until the folder is shown again. + const lateNote = useRef(null); + /** True when the person has gone to a source since work on the chat's folder began: its result is the folder's, so it waits there. */ + const away = (problem: string | null) => { + if (rootRef.current === null) return false; + if (problem) lateNote.current = problem; + return true; + }; + const home = useMemo(() => folderReader(sessionId), [sessionId]); + const reader = useMemo(() => (root === null ? home : sourceReader(sessionId, root)), [home, sessionId, root]); + const source = root === null ? undefined : (sources.find((x) => x.id === root) ?? { id: root, label: root, writable: false }); + // Only the chat's folder is made, uploaded into, renamed in and deleted from here; a source can be saved in where it says so. + const writable = !source || source.writable; + // A source that went away while it was shown stays a tab, so that there is a way back. + const tabs = source && !sources.some((x) => x.id === source.id) ? [...sources, source] : sources; const picker = useRef(null); + const folderPicker = useRef(null); const dirty = !!file && !file.binary && !file.loading && draft !== file.saved; const dirtyRef = useRef(dirty); @@ -188,8 +215,8 @@ export const FilesPanel = memo(function FilesPanel({ // Kept while there are changes, for a panel that is unmounted with them: switching chat or leaving the page. useEffect(() => { if (!keepDraft) return; - keepFileDraft(sessionId, dirty && file ? { path: file.path, text: draft, saved: file.saved, mtime: file.mtime, size: file.size, writing } : null); - }, [keepDraft, sessionId, dirty, file, draft, writing]); + keepFileDraft(sessionId, dirty && file && root === null ? { path: file.path, text: draft, saved: file.saved, mtime: file.mtime, size: file.size, writing } : null); + }, [keepDraft, sessionId, dirty, file, draft, writing, root]); // Written before it goes: unmounting must not lose what was typed a moment ago. Closing the panel on purpose forgets it first. useEffect(() => (keepDraft ? () => flushFileDraft(sessionId) : undefined), [keepDraft, sessionId]); // An edit brought back may be of a file that changed meanwhile: told the way a save would, instead of at the save. @@ -207,19 +234,21 @@ export const FilesPanel = memo(function FilesPanel({ // Once, for what the panel opened with. // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const root = folder.split("/").filter(Boolean).pop() || "folder"; + const rootName = folder.split("/").filter(Boolean).pop() || "folder"; const loadDir = useCallback( (path: string) => { const ask = ++listAsk.current; setListing(true); - return api - .listFiles(sessionId, path) + return reader + .list(path) .then((r) => { if (ask !== listAsk.current) return; setEntries(r.entries); setTruncated(r.truncated); - setListError(null); + // The chat's folder shows what its work said while a source was in front (see `away`); a source starts clean. + setListError(rootRef.current === null ? lateNote.current : null); + if (rootRef.current === null) lateNote.current = null; }) .catch((e) => { if (ask !== listAsk.current) return; @@ -230,23 +259,37 @@ export const FilesPanel = memo(function FilesPanel({ if (ask === listAsk.current) setListing(false); }); }, - [sessionId], + [reader], ); useEffect(() => { void loadDir(dir); }, [loadDir, dir]); + // The trees other extensions gave, asked for on their own: the chat's folder is shown without waiting for them, and without them should the + // question fail (the ones there were stay). + const loadSources = useCallback( + () => + listSources(sessionId).then( + (r) => setSources(r.sources), + () => {}, + ), + [sessionId], + ); + useEffect(() => { + void loadSources(); + }, [loadSources]); + const loadFile = useCallback( - (path: string, write = false) => { + (path: string, write = false, from = reader) => { const ask = ++fileAsk.current; setWriting(write); setChanged(false); setSaveError(null); setFile({ path, loading: true, binary: false, size: 0, mtime: 0, saved: "" }); setDraft(""); - return api - .readFile(sessionId, path) + return from + .read(path) .then((r) => { if (ask !== fileAsk.current) return; const text = r.binary ? "" : r.content; @@ -259,7 +302,7 @@ export const FilesPanel = memo(function FilesPanel({ setFile({ path, loading: false, error: (e as Error).message, binary: false, size: 0, mtime: 0, saved: "" }); }); }, - [sessionId], + [reader], ); /** @@ -291,7 +334,7 @@ export const FilesPanel = memo(function FilesPanel({ setSaveError(null); }; if (!watching) setRereading(true); - return (watching ? api.readFileIfChanged(sessionId, path, known!) : api.readFile(sessionId, path)) + return (watching && reader.readIfChanged ? reader.readIfChanged(path, known!) : reader.read(path)) .then((r) => { if (ask !== fileAsk.current) return; if ("unchanged" in r) return; @@ -316,12 +359,13 @@ export const FilesPanel = memo(function FilesPanel({ if (!watching) setRereading(false); }); }, - [sessionId], + [reader], ); // The open file is looked at now and then: a change made outside this panel (another device, a cron job, the agent's shell) shows by itself. // Not while the page is hidden, and a look that finds nothing new changes nothing on screen. - const watched = file && !file.loading && !file.error ? file.path : null; + // Not in a source: each look would fetch the whole file from wherever it is (a cloud, over the network), every few seconds, for as long as it is open. Refresh reads it again. + const watched = file && !file.loading && !file.error && !source ? file.path : null; useEffect(() => { if (!watched) return; const timer = setInterval(() => { @@ -339,6 +383,8 @@ export const FilesPanel = memo(function FilesPanel({ useEffect(() => { if (!activity || activity.seq <= handled.current) return; handled.current = activity.seq; + // The agent works in the chat's folder: its paths mean nothing in a source, which the panel is then not following (looking at one lets go). + if (rootRef.current !== null) return; // What the agent did may have added to a folder, wherever the panel is looking. void loadDir(dir); // It changed the file that is open: that shows, followed or not, read again where it is. An edit in the way is asked about, not replaced. @@ -346,7 +392,7 @@ export const FilesPanel = memo(function FilesPanel({ if (!following) return; if (dirtyRef.current) return; setDir(parentOf(activity.path)); - void loadFile(activity.path); + void loadFile(activity.path, false, home); // Only a new activity should do this, not a change of folder. // eslint-disable-next-line react-hooks/exhaustive-deps }, [activity?.seq]); @@ -364,8 +410,11 @@ export const FilesPanel = memo(function FilesPanel({ void mayLeave().then((ok) => { if (!ok || ask !== revealAsk.current) return; setFollowing(false); + // A path of the chat's folder: from a source it is shown after going back to the folder. + if (rootRef.current !== null) rootGen.current++; + setRoot(null); setDir(parentOf(target)); - void loadFile(target); + void loadFile(target, false, home); }); // Only a new ask, not a new folder or a new file. // eslint-disable-next-line react-hooks/exhaustive-deps @@ -379,6 +428,27 @@ export const FilesPanel = memo(function FilesPanel({ setDir(path); }; + /** Looks at another tree: the chat's folder (null) or a source. True once it is shown; false if the person kept what they had open. */ + const switchRoot = async (next: string | null): Promise => { + if (next === rootRef.current) return true; + if (!(await mayLeave())) return false; + setFollowing(false); + // What was asked of the one that is left is not the answer for the one that comes: both lists and the file are stale at once. + fileAsk.current++; + listAsk.current++; + rootGen.current++; + setFile(null); + setEntries([]); + setTruncated(false); + setListError(null); + setListing(next !== null); + setRenaming(null); + setCreating(null); + setDir(""); + setRoot(next); + return true; + }; + const openEntry = async (entry: FileEntry) => { if (entry.type === "link") return; if (!(await mayLeave())) return; @@ -399,27 +469,33 @@ export const FilesPanel = memo(function FilesPanel({ }; // Clicked again while following, it lets go: what is open stays where it is. - const toggleFollow = () => { + const toggleFollow = async () => { if (following) return setFollowing(false); + // The agent's files are the chat folder's. + if (!(await switchRoot(null))) return; setFollowing(true); if (activity && !dirtyRef.current) { handled.current = activity.seq; setDir(parentOf(activity.path)); - void loadFile(activity.path); + void loadFile(activity.path, false, home); } }; const save = async (overwrite = false) => { - if (!file || file.binary || saving) return; + if (!file || file.binary || saving || !writable) return; setSaving(true); setSaveError(null); + const gen = rootGen.current; try { - const r = await api.saveFile(sessionId, file.path, draft, overwrite ? undefined : file.mtime); + const r = await reader.save(file.path, draft, overwrite ? undefined : file.mtime); + // The person went to another tree while it was written: the file shown there is not this one. + if (gen !== rootGen.current) return; // A read of the file still out (Refresh, the agent's change) may have found it as it was before this save: shown now, its old text would pass for saved. fileAsk.current++; setFile({ ...file, saved: draft, mtime: r.mtime, size: r.size, error: undefined }); setChanged(false); } catch (e) { + if (gen !== rootGen.current) return; // By the code, not the sentence: the server's wording is not this page's to depend on. if (isApiError(e) && e.body.code === "conflict") setChanged(true); else setSaveError((e as Error).message); @@ -450,6 +526,7 @@ export const FilesPanel = memo(function FilesPanel({ } catch (e) { problem = (e as Error).message; } + if (away(problem)) return; // After the reload, which clears an error: this one is about what was just tried. await loadDir(dir); if (problem) setListError(problem); @@ -490,6 +567,7 @@ export const FilesPanel = memo(function FilesPanel({ const unsaved = entry.type === "dir" && !entry.link ? await api.fileUnsaved(sessionId, path).then((r) => r.unsaved, () => null) : null; const gone = await deleteAsking(unsaved, (u) => askToRemove(entry, u), (discard) => api.deleteFile(sessionId, path, discard)); if (!gone) return; + if (away(null)) return; // What is open and shown now, not when the delete was clicked: the person may have moved on while it was asked. const open = fileRef.current; if (open && within(path, open.path)) { @@ -508,6 +586,7 @@ export const FilesPanel = memo(function FilesPanel({ return next; }); } + if (away(problem)) return; await loadDir(dirRef.current); if (problem) setListError(problem); }; @@ -529,6 +608,7 @@ export const FilesPanel = memo(function FilesPanel({ } catch (e) { problem = (e as Error).message; } + if (away(problem)) return; await loadDir(dir); if (problem) return setListError(problem); // A new file is made to be written in. @@ -538,23 +618,45 @@ export const FilesPanel = memo(function FilesPanel({ } }; - /** Into the folder being looked at. A name that is taken gets a number; nothing is replaced. */ - const upload = async (files: File[]) => { - if (!files.length) return; + /** + * A tree of files and folders into the folder being looked at. The folders are made first, one by one, then each file goes up with the + * upload route that single files use. A name that is taken gets a number, for a folder as for a file; nothing is replaced. + * A file that fails does not stop the rest, and the counter counts every file of the tree. + */ + const upload = async (tree: Tree) => { + const count = countFiles(tree); + if (!count && !tree.dirs.length && !tree.problems.length && !tree.unreadable.length) return; const into = dir; - setUploading((n) => n + files.length); - const problems: string[] = []; - for (const file of files) { - try { - await api.uploadFile(sessionId, into, file); - } catch (e) { - problems.push((e as Error).message); - } finally { - setUploading((n) => n - 1); - } + setUploading((n) => n + count); + const result = await uploadTree( + { createFolder: (at, name) => api.createFolder(sessionId, at, name), uploadFile: (at, file) => api.uploadFile(sessionId, at, file) }, + (e) => isApiError(e) && e.body.code === "exists", + into, + tree, + () => setUploading((n) => n - 1), + ); + const problems = [ + ...tree.problems.map((path) => t("Skipped {path}: it is not a path inside the folder.", { path })), + ...tree.unreadable.map((path) => t("Skipped {path}: it could not be read.", { path })), + ...result.problems, + ]; + const note = problems.length ? [...problems.slice(0, 5), ...(problems.length > 5 ? [t("…and {n} more.", { n: problems.length - 5 })] : [])].join(" ") : null; + if (away(note)) return; + await loadDir(dirRef.current); + if (note) setListError(note); + }; + /** Plain files, as the file picker and a browser without folder drops give them. */ + const uploadFiles = (files: File[]) => upload({ dirs: [], files, problems: [], unreadable: [] }); + /** What was dropped, as the browser's entries (taken during the drop): folders are walked to their end first. */ + const uploadEntries = async (entries: Entry[]) => { + setReading(true); + let tree: Tree; + try { + tree = await treeOfEntries(entries); + } finally { + setReading(false); } - await loadDir(dir); - if (problems.length) setListError(problems.join(" ")); + await upload(tree); }; const crumbs = dir ? dir.split("/") : []; @@ -564,8 +666,8 @@ export const FilesPanel = memo(function FilesPanel({
- {!file && ( + {!file && !source && ( <> + { + const files = [...(e.target.files ?? [])]; + e.target.value = ""; + void upload(treeOfPaths(files)); }} /> )} - + {!source && ( + + )} - - - + {!source && ( + + + + )}
- {showPath && ( + {/* Only where an extension gave a tree: the chat's folder is then one of two or more, and the others are as near as it is. */} + {tabs.length > 0 && ( +
+ {[{ id: null, label: rootName, title: folder }, ...tabs.map((x) => ({ id: x.id as string | null, label: x.label, title: x.label }))].map((tab) => ( + + ))} +
+ )} + {showPath && !source && ( // Cut at the start, not the end: what tells folders apart is the last part. rtl moves the ellipsis; bdi keeps the path itself left to right.
{dir ? `${folder}/${dir}` : folder} @@ -679,6 +823,7 @@ export const FilesPanel = memo(function FilesPanel({ + {source ? `${source.label}: ` : ""} {file.path} {dirty && ( @@ -689,7 +834,12 @@ export const FilesPanel = memo(function FilesPanel({ {!file.loading && !file.error && ( {bytesLabel(file.size)} )} - + {!writable && !file.loading && !file.error && ( + + {t("Read-only")} + + )} + {!file.binary && !file.loading && !file.error && isMarkdown(file.path) && ( @@ -706,7 +856,7 @@ export const FilesPanel = memo(function FilesPanel({ {asPage ? : } )} - {!file.binary && !file.loading && !file.error && ( + {writable && !file.binary && !file.loading && !file.error && (