Skip to content

feat(folder): open a folder in a sidebar that stacks with the tab column - #964

Open
ryanhoggpm wants to merge 6 commits into
sftwrdotdev:masterfrom
ryanhoggpm:feat/folder-explorer
Open

ryanhoggpm wants to merge 6 commits into
sftwrdotdev:masterfrom
ryanhoggpm:feat/folder-explorer

Conversation

@ryanhoggpm

Copy link
Copy Markdown

What this is

A proposal, opened knowing #149 declined a folder tree. It adds Open Folder to the main menu and shows the folder as a tree in a side column. When the tabs from #963 are in a side column, the open files and the folder stack in one column, with a draggable divider and a setting for which goes on top. Otherwise the folder takes a side of its own.

It is stacked on #963 (vertical tabs, #884), so the first two commits here are that PR. Only the last commit, fcf2301, is new. If this isn't something you want, no hard feelings; it will live on in my fork.

New settings, all defaulting to off or to today's layout:

  • Folder Sidebar Side: left or right. Inactive while the tabs are in a column, because then the folder joins that column.
  • Sidebar Stack Order: open files on top, or folder on top.
  • Show All Files: off by default.

The divider position and the column width are remembered. The last opened folder reopens on launch in the main window. A folder deleted since then is dropped quietly rather than shown as an error.

Mechanism

#149 named two costs. This is how the PR answers each one.

Keeping a tree in sync with the disk. Nothing walks a tree. read_folder_entries lists one level, and the sidebar asks for a folder's children only when the reader expands it. Opening a folder with node_modules in it costs one read_dir of its top level. Only expanded folders are watched, each with a non-recursive notify watcher, and the watch is dropped on collapse, on closing the folder, and when the window is destroyed. A change emits folder-changed with that folder's path, coalesced over 200 ms through the existing coalesced helper, and the frontend re-reads that one level. The Rust side keeps no tree state, so there is nothing on that side to drift.

Folders full of files Markpad doesn't open. By default the tree lists only folders and the files Markpad opens, decided by the same MARKDOWN_LINK_EXTENSIONS list the Open dialog filters on. There's no third copy of the list. With Show All Files on, the other files appear dimmed. Clicking one hands it to the system's default app through openWithSystem, which is the existing document-link path factored out of handleDocumentClick. The is_launchable_path guard therefore applies to the tree too: a .exe or .app is revealed in its folder, never run.

Links on Windows. The tree does not list symlinks or junctions on Windows. If a folder someone unzipped or cloned contained a link to \\host\share, following it would make Windows offer the user's NTLM credentials to that host as soon as the parent folder was expanded, with no click. That is the attack #876 closed for documents. Entry types come from read_dir's own records, so a listing never touches a link's target. I first tried checking where each link pointed, but deciding which UNC spellings, link chains and check-then-use races are safe is a losing game. Leaving links out needs none of it. On macOS and Linux links are listed and followed, because a network share there is a mount the user made.

Scope

  • No file operations (create, rename, delete, move). The tree is for finding and opening documents. Its right-click menu has only Copy Full Path and Open File Location, which reuse existing commands and labels.
  • No keyboard shortcut for Open Folder. Ctrl+Shift+O is taken by Monaco's Go to Symbol, and I didn't want to pick a new chord for you.
  • One folder per window. The folder is restored only in the main window, so a torn-off tab window doesn't reopen it.
  • English strings only. Other locales fall back as i18nCoverage expects.
  • Known gap: someone who can write to the open folder could swap a real folder for a junction between listing and expanding. That needs write access to the folder the user has open, and document links carry the same exposure today.

Tests

  • src-tauri/src/folder.rs: folders sort first and by name ignoring case; dot entries are hidden and only one level is read; a file or a missing path is not a folder. On Unix, a link to a folder lists as a folder and a dangling link is dropped. On Windows, a link to \\unreachable.invalid\share is left out of the listing. That test skips itself when the runner can't create symlinks.
  • scripts/folderSidebar.spec.ts (vitest, fake disk behind a stubbed invoke): opening reads and watches only the top level; collapsing unwatches the folder and its descendants; folder-changed re-reads only a loaded folder; closing forgets everything; restore drops a folder that no longer exists; the path helpers handle both separators.
  • scripts/settingsPersistence.spec.ts: the four new settings round-trip, and invalid values fall back.
  • scripts/localFileLinks.test.ts: retargeted from handleDocumentClick to openWithSystem, where the openPath call, the launchable check and the failure toast now live. It asserts the same order and the same try/catch. It also asserts the click handler still resolves the raw href before calling openWithSystem, and still falls back to openUrl.

Verification

npm audit                  0 vulnerabilities
npm run check              0 errors, 0 warnings
npm test                   1116 pass
npm run test:vitest        501 pass, 1 timeout (see below)
cargo fmt --check          clean
cargo clippy --all-targets -- -D warnings   clean
cargo test                 189 pass

The vitest timeout is diagramThemeRefresh.spec.ts hitting the 5 s limit under full-suite load. It passes on its own, and it times out on unmodified master too (noted in #963).

Run by hand on Linux (WSLg, WebKitGTK 2.52): opened this repo as a folder; expanded samples and opened files from it with the tabs at each position; swapped the stack order, dragged the divider and collapsed each section; turned on Show All Files and clicked package.json, which opened in the system app; touch samples/new-note.md from a shell appeared in the tree.

Not verified: Windows and macOS at runtime. The Windows link test has only been reasoned about, not run, so CI's Windows job will be its first run.

ryanhoggpm and others added 6 commits October 8, 2026 12:58
Adds a Tab Position setting (Top, Left Column, Right Column). With a
column, the open documents are listed down one side of the window under
the title bar, reusing TabList and Tab so the context menu, middle-click
close and drag-to-reorder behave the same. The title bar shows the
window title instead of the strip, the column is resizable, and the
window tag colour runs along its inner edge. The default stays Top, so
nothing changes for existing installs.
Tab Position gains Bottom: the same horizontal strip, docked along the
bottom edge of the window instead of the title bar. TabColumn becomes
TabDock, which handles the bottom strip and the side columns. Only a
strip inside the title bar is a window drag region.
- external-change-bar sits inside .content-area, so it spans the document
  instead of covering the column's first rows.
- .content-area beside the column is absolute, not fixed: with no z-index it
  opens no stacking context, so the home page's context menu stacks above
  the column and title bar again.
- the column resizer is a focusable separator with the outline handle's
  keys and its range in aria-valuemin/max.
- settings.tabPlacement is the one answer the title bar and the column read.
Adds Open Folder to the main menu. The folder appears as a tree in a
side column. With the tabs in a side column, the folder and the open
files stack in one column, in the order chosen by Sidebar Stack Order,
with a draggable divider; otherwise the folder takes its own side.

The tree reads one level per expand and watches only expanded folders,
non-recursively, so a large folder costs one read_dir of what is on
screen. By default it lists only the files Markpad opens; Show All Files
lists the rest dimmed and hands them to the system's default app through
the same path document links already use.

On Windows the tree does not list symlinks or junctions. Following one to
a UNC path would make Windows offer the user's NTLM credentials to that
host as soon as its parent folder was expanded (the attack sftwrdotdev#876 closed for
documents); entries are typed from read_dir's own records, so a listing
never touches a link's target.
…d folder

- folder.rs keeps one RecommendedWatcher per window and watches or unwatches
  each expanded folder on it, instead of a watcher (an inotify instance and
  thread, or an FSEvents stream) per folder. Events map back to the folder
  the frontend named through its named and canonical paths, since FSEvents
  reports resolved ones, and are coalesced per window.
- a subfolder whose read fails is collapsed the way toggle collapses it, so
  its listing, its descendants and their watches go too.
- folder-changed is listened for on the current window only.
- the module comment no longer claims a followed link cannot reach the
  network outside Windows.
@PathGao
PathGao force-pushed the feat/folder-explorer branch from fcf2301 to 3ae7628 Compare October 9, 2026 08:27
@PathGao

PathGao commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator

Rebased onto the updated #963 (force-pushed, so please reset your local branch) and added one commit. The conflict resolution is folded into your commit: TabDock and TabColumn are gone in favour of Sidebar. watch_folder now keeps one watcher per window and adds or removes paths on expand and collapse. Before, every expanded folder had its own watcher, which is one inotify instance each on Linux (128 per user by default) and a recursive FSEvents stream each on macOS. Events are matched by canonical path too, since FSEvents reports /private/var for /var. A subfolder that fails to read now goes through the same collapse as a toggle, so its descendants and watches are dropped with it. The module comment no longer says links on macOS only reach mounts the user made: autofs mounts /net/<host> on first access.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants