Appearance
File Explorer
The desktop and Android apps include a right-side sidebar for browsing the files in a project's working directory on the daemon node that owns it, plus file operations (create, rename, move, copy, delete, download) and host upload/clipboard integration. The git changes panel and built-in editor are tracked separately.
Where it lives
The right sidebar is part of the Workspace view (/workspace).
- Desktop (md+): a 288 px aside on the right of the pane area. It is collapsed by default; a collapse button hides it and a small toggle on the right edge of the pane area re-opens it. The open/closed choice is remembered for the current app session via
sessionStorage["right-sidebar-collapsed"]and resets to collapsed when the app restarts. Drag the handle on the sidebar's left edge to resize it (220-560 px); the width is remembered across restarts vialocalStorage["hive-right-sidebar-width"]. - Mobile (< md): a sheet drawer that slides in from the right. The trigger lives in the workspace tab bar's trailing slot. Safe-area insets (
var(--sat),var(--sab),var(--sar)) are honored so the sheet sits clear of system chrome on Android.
The sidebar now shows three tabs: Files, Git, Commands. The file explorer itself remains the read-only files view described here; the git changes panel and command usage panel are documented separately.
What it shows
The Files tab lists the contents of the active project's working directory. Folders sort first, then files, both alphabetically (case-insensitive). The .git directory is hidden. Click a folder to expand it; children load lazily on first expand and are cached until the page is refreshed or the project changes.
A refresh button in the panel header re-fetches the root listing without clearing children of expanded subdirectories.
Auto-refresh
While the Files tab is open the explorer keeps the tree in sync with the disk automatically: changes made outside the app (a terminal session, git checkout, a build, or another node) appear without pressing refresh. The daemon watches the project's working directory (recursively, via the notify crate), debounces a burst of events, and pushes a coarse project_files_changed. The app coalesces those and re-lists the directories it has cached - the root plus any expanded subfolders - so the whole visible tree updates.
The watch is established when the Files panel mounts (and re-targeted when the active project changes), and torn down on unmount, on project switch, and when the connection drops. .git internals and the daemon's own atomic-write temp files (*.tmp) are filtered out so routine churn doesn't trigger refreshes.
In cluster mode the watch is routed to the node that owns the project: the owner runs the watcher and streams change events back over the peer transport (subscribe_project_files / project_files_changed), so projects hosted on a remote node auto-refresh just like local ones.
Selecting a file marks it as the currently-selected entry in the useFilesStore Pinia store. Milestone 3 (built-in editor) consumes this selection to open the file.
File operations
Each entry has a kebab (⋮) menu, and the panel header has New File / New Folder / Paste buttons for the project root. On touch devices the kebab is always visible; on desktop it appears on row hover and the same menu also opens on right-click. (Right-click is suppressed on touch devices, which restores the native context menu, so the kebab remains the touch affordance.) Available actions:
- New File / New Folder - prompts for a name and creates it in the chosen directory. Creation is exclusive: it fails if the name already exists.
- Rename - renames an entry in place (a same-directory move).
- Copy / Cut → Paste - Copy or Cut places the entry on a single-slot clipboard; Paste drops it into the target directory. Cut performs a move and then clears the clipboard; Copy can be pasted repeatedly. Directories copy recursively. Pasting onto an existing name is refused (no clobber).
- Delete - removes a file, or a directory and everything inside it, after a confirmation dialog.
- Download - saves the file to the local device, streamed in chunks with no size cap (the 25 MiB binary-read limit does not apply here). On desktop the user picks a location via the native Save-As dialog; on mobile - which has no reliable Save-As - the file is written into the Downloads directory. The daemon serves the file in 8 MiB windows (stateless ranged reads) and the app writes each window straight to disk in Rust, so the bytes never buffer whole in memory or pass through the webview. A toast shows download progress.
- Copy to clipboard (desktop only) - copies a file to the host OS clipboard so it can be pasted in the host's file manager. The bytes are fetched from the daemon, staged into a temp file locally, then placed on the clipboard via the clipboard plugin. Files only; hidden on mobile.
Failures surface as a toast; the tree refreshes the affected directories on success. Each mutation carries a client-generated request_id that the daemon echoes back so the in-flight operation resolves precisely.
Uploading from the host
- Drag & drop (desktop) - drag one or more files from the host onto the explorer to upload them. Dropping onto a folder row uploads into that folder; dropping onto the empty area uploads into the project root. The active drop target is highlighted.
- Paste (Ctrl/Cmd+V) - with a folder or the root selected, paste uploads any files currently on the host OS clipboard into that directory. When the clipboard holds no host files, it instead pastes the explorer's internal cut/copy clipboard (the in-project move/copy described above).
- Copy / Cut (Ctrl/Cmd+C / Ctrl/Cmd+X) - with a file selected, Copy places it on the host OS clipboard (and the internal clipboard for in-project paste); Cut marks it for an in-project move. Click a file, folder, or the empty root area to set the selection these shortcuts act on.
Uploads are capped at 25 MiB per file (the binary read/write cap); larger files are rejected with a toast. A name that already exists in the destination is suffixed (name (1).ext) so a drop never silently overwrites. Each uploaded file is chowned to the project's run-as user (see below).
Wire protocol
Two new client → daemon messages and three responses make up the file protocol. All operations are scoped by project_id and validated against that project's working_dir before any IO is attempted.
ListProjectFiles
jsonc
// client → daemon
{ "type": "list_project_files", "project_id": "<uuid>", "path": "src" }path is interpreted relative to project.working_dir. null or an empty string resolves to the project root. The daemon validates the resolved path stays inside working_dir (canonicalize + ancestor check) and rejects path traversal with project_file_error.
jsonc
// daemon → client (success)
{
"type": "project_file_list",
"project_id": "<uuid>",
"path": "src",
"entries": [
{ "name": "components", "path": "src/components", "kind": "dir", "size": 0 },
{ "name": "lib.rs", "path": "src/lib.rs", "kind": "file", "size": 4096 }
]
}ReadProjectFile
jsonc
// client → daemon
{ "type": "read_project_file", "project_id": "<uuid>", "path": "README.md" }jsonc
// daemon → client (success)
{
"type": "project_file_content",
"project_id": "<uuid>",
"path": "README.md",
"content": "...",
"sha256": "abc123..."
}The sha256 is the lowercase hex digest of the raw bytes. Milestone 3 uses it for optimistic-concurrency checks on writes.
ReadProjectFileChunk
Backs the streaming Download action for files of any size. Each request seeks to offset and returns up to len bytes (the daemon caps len to its own 8 MiB per-frame bound); the client loops windows until eof, writing each to disk. The read is stateless (seek per request) with no total-size cap, so no whole-file copy is ever buffered in memory on either side.
jsonc
// client → daemon
{ "type": "read_project_file_chunk", "request_id": "d1", "project_id": "<uuid>",
"path": "build/artifact.bin", "offset": 0, "len": 8388608 }
// daemon → client (success) - `data` is base64-encoded
{ "type": "project_file_chunk", "request_id": "d1", "data": "...",
"offset": 0, "total_size": 134217728, "eof": false }A failed window answers with project_file_chunk_error (carrying the originating request_id). In-app preview of binary files is unchanged and still uses read_project_file_binary, which keeps the 25 MiB cap.
ProjectFileError
Returned when the project doesn't exist on this daemon, the path escapes the working directory, the file is missing, the file is binary (NUL byte detected in the prefix), or the file exceeds the 1 MB read limit.
jsonc
{
"type": "project_file_error",
"project_id": "<uuid>",
"path": "../etc/passwd",
"message": "path escapes project root"
}Filesystem mutations
The create / rename / move / copy / delete operations share one request/response shape. Each request carries a client-generated request_id; the daemon answers with project_fs_action_result (success) or project_fs_action_error (failure), both echoing that request_id. Paths are project-relative and validated against working_dir; create/move/copy refuse to overwrite an existing destination.
jsonc
// client → daemon (rename / move)
{ "type": "move_project_entry", "project_id": "<uuid>",
"from": "a.txt", "to": "b.txt", "request_id": "r3" }
// daemon → client (success)
{ "type": "project_fs_action_result", "project_id": "<uuid>",
"request_id": "r3", "path": "b.txt" }The full set of request types is create_project_file, create_project_directory, move_project_entry, copy_project_entry, delete_project_entry, and upload_project_file. See protocol.md for every payload.
upload_project_file carries the file bytes (base64-encoded as data on the wire) and creates the file exclusively (no clobber), then chowns it to the run-as user. It reuses the same project_fs_action_result / project_fs_action_error responses.
Download streams over the read_project_file_chunk / project_file_chunk messages above. The Tauri commands download_project_file_to_path (desktop, after the native Save-As dialog) and download_project_file_to_downloads (mobile) pull the windows and write each straight to disk in Rust, emitting a hive:fs-download-progress event for the progress toast. Copy to clipboard still reuses the 25 MiB binary read, then stages the bytes via the stage_clipboard_file command before handing the temp path to the clipboard plugin.
Multi-node behavior
When the project's node_id points to a peer other than the daemon that received the request, the daemon forwards the message over the inter-node WebSocket and relays the peer's response back to the client. This mirrors how ListOsUsers and GetDaemonLog behave for per-node operations. The client sees a single response - the forwarding is invisible. Filesystem mutations forward the same way and surface peer-unreachable failures as project_fs_action_error.
Standalone daemons (no cluster) always handle requests locally.
Limits and rejections
- Read size cap: 1 MiB. Files larger than this return an error.
- Binary files: any NUL byte in the first 8 KiB rejects the read.
- Non-UTF-8 text: returns an error (read as bytes is not exposed in M1).
- Symlinks pointing outside the working directory: rejected by canonicalization.
- The
.gitdirectory is filtered from listings.
Run-as users
Reads and listings run as the daemon's own user; the security boundary is path containment, not user impersonation.
Creation and upload additionally fix ownership: when a project's run_as_users map resolves to an OS user for this node (node-keyed entry, falling back to the "*" wildcard - the same lookup sessions use), newly created files, new directories, and uploaded files are chowned to that user. This keeps files owned by the same user sessions run as, instead of the daemon's user.
The chown is a no-op when the resolved user already matches the daemon's effective uid, so single-user (non-root) deployments are unaffected. When the users differ it requires privilege - i.e. the daemon running as root - exactly like runuser-based session spawning. A chown that cannot be performed surfaces as project_fs_action_error rather than silently leaving the wrong owner. Ownership fixing is POSIX-only; on Windows it is a no-op.
Frontend integration
- Pinia store:
src/stores/files.ts(useFilesStore) - Action controller:
src/composables/useFileExplorerActions.ts(provide/inject; hosts dialog state and routes menu actions to the store) - Components:
src/components/sidebar-right/RightSidebar.vue- tabs container and mount pointFileExplorer.vue- header + root listing + hosts the shared dialogsFileTreeNode.vue- recursive tree row + per-entry kebab menuFileNameDialog.vue- single-input dialog for create / rename
- Tauri commands:
src-tauri/src/commands/files.rslist_project_files,read_project_file,read_project_file_binary,write_project_filewatch_project_files(projectId)/unwatch_project_files(projectId)- subscribe / unsubscribe the live auto-refresh watchcreate_project_file,create_project_directory,move_project_entry,copy_project_entry,delete_project_entryupload_project_file(projectId, path, dataBase64, requestId)- upload from webview bytes;upload_host_file(projectId, path, hostPath, requestId)- upload by reading a local path (OS clipboard / native drag)download_project_file_to_path(projectId, srcPath, destPath)/download_project_file_to_downloads(projectId, srcPath, filename)- stream a file of any size to disk for Download (desktop Save-As / mobile Downloads); emithive:fs-download-progresssave_file_to_disk(path, dataBase64)- small local write helperstage_clipboard_file(name, dataBase64) -> path- stage a temp file for the host clipboard copy
- Clipboard plugin:
tauri-plugin-clipboard(Rust) /tauri-plugin-clipboard-api(JS), registered desktop-only inlib.rs. Used forwriteFiles/readFiles/hasFiles; capabilities granted incapabilities/default.json. - Events (registered in
src/composables/useTauriEvents.ts):hive:project-file-list,hive:project-file-content,hive:project-file-binary,hive:project-file-error,hive:project-file-saved,hive:project-file-write-conflict,hive:project-fs-action-result,hive:project-fs-action-error,hive:project-files-changed(auto-refresh) - Daemon watcher:
hive-daemon/src/file_watcher.rsbuilds the recursivenotifywatcher; the per-connection watch lifecycle lives inhive-daemon/src/server/mod.rsand the cross-node owner-side watcher inhive-daemon/src/cluster_bridge.rs