Skip to content

Two tabs at once, side by side, with one sidebar - #492

Open
cerede2000 wants to merge 3 commits into
nxzai:mainfrom
cerede2000:panes-side-by-side
Open

cerede2000 wants to merge 3 commits into
nxzai:mainfrom
cerede2000:panes-side-by-side

Conversation

@cerede2000

@cerede2000 cerede2000 commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

This sits on #494, which sits on #493. Only the last commit is this one; review it
after those, or read Two tabs at once, side by side, with one sidebar on its own.

Two tabs drawn at once, side by side. Off unless you ask for it: right-click a tab →
Show on the right, and the two become one entry in the strip, a ↔ b. Drag the
divider, Ungroup to undo. With no pair, every screen behaves exactly as it does
with tabs alone.

What a reader gets

A pair is two tabs, not a split of the window. So there can be as many pairs as
there are tabs, and either half holds anything a tab holds — a folder, a document in
its viewer, the text editor, a shell, a comparison. Copy on the left, paste on the
right. Drag a file from one half to the other. The two halves of one folder are two
readers of it: each keeps its own listing, its own selection and its own place in
it.

One sidebar, one address bar, for the window. They were inside the browser layout
and are now drawn once above every layout, which is what lets a pane hold a screen
that is not in that layout. The toolbar above a pane opens the same sidebar the
window draws.

How it is built, and why that way

What belongs to a place is the pane's own; what belongs to a person stays one to
the window. stores/files/folderTab.js drew that line already — this is the rest of
it.

  • components/TabPane.vue is a pane.
  • composables/paneTab.js is how a screen on the far side of that line asks which
    side it is on. With one pane it answers exactly what useRoute() and "the tab in
    front" answered before; with two it answers for the half the screen is drawn in.
    Every screen asks it now — the folder view, the breadcrumb, the selection, a
    document, the editor, a comparison, a shell — because each of them was reading the
    window, and the window has one answer.
  • composables/paneBoxes.js measures where each pane is, for the two things drawn
    outside the page that still have to sit on one: a document in its viewer and a
    shell. Both are told which panes are on screen rather than which tab is in front.
  • The routes for a document, the editor, a comparison and the trash are flat now. A
    layout that only passes its children through still breaks the symmetry between the
    halves: the focused pane draws the outermost match and the other draws the deepest.

Four faults in the detail of it

Each is one sentence said a different way, and each has a test that fails without
the fix.

A pane draws a tab, not an address. Bringing a tab forward is two moves — the
store first, the address bar after — so the pane that had just taken the focus drew
the router's screen for one tick while the router was still on the address of the
half being left. A listing was replaced by a document and replaced back a frame
later, which is why a press in the other half had to be made twice: the row it was
pressed on no longer existed when the button came up. A pane keeps its own screen
until its tab and the address agree.

A destroyed pane still names its tab. A page asking "am I still my tab?" on the
way out is asking nothing in a pane being taken off screen — so the editor in the
half beside the reader read that as "the address changed under me" and threw away
where its reader was. Measured: the left half came back on its line, the right half
at the top of the file, every time. It is the tab in front and the window's own
address that say whether anybody navigated.

A place in a folder is a row, not a number of pixels. A pane that comes back a
different width — one of a pair, or a pair becoming one — re-flows its listing, and
the same number is then a different place.

A listing speaks only for an address it is the screen for. A pane handed a tab
that holds a file had, for one tick, a listing whose address was the file's. Reading
it asked the server to list a file as a folder: ENOTDIR: not a directory, a 500,
and nothing on screen to explain it.

Verified

  • npm run test:unit in frontend/: 203 files, 3 522 tests, green on main
    plus this stack (and the small FFmpeg catalogue fix, without which main's own
    i18n suites fail — fifteen failures that are not this branch's).
  • scripts/check-batch.sh: merged onto main in a fresh tree, the frontend bundles,
    the backend loads and migrates, and every client call has a route that answers it.
  • npm run test:e2e: the journey through the whole application, in a real browser
    against the real server serving the real build, walks a pair end to end — two tabs
    on one folder each keeping their place and selection, a document opened from the
    bottom of one half and shut again, crossing between two editors, a press in the
    half beside a document taken on the first press.

Benjy and others added 3 commits October 3, 2026 12:15
Off by default, and turned on in Settings → Preferences → Browse in tabs.

A tab is an address rather than a folder, so the strip holds anything the
application can be at: a folder, a document, the text editor, a terminal,
the trash, the settings. Each tab keeps what it was on — its listing, what
was selected in it, the rename it was in the middle of, the place it was
scrolled to. The clipboard is deliberately not per tab: copying in one and
pasting in another is the point of having two.

One rule opens anything behind: the platform modifier held with the gesture
that opens the thing — the double click on a row, the single click on a
favourite, a volume or a place on the home page, and Enter on whatever the
keyboard is on. On a row that modifier with a single click still adds to the
selection, which is worth more than a tab. The middle button says the same
thing without a modifier, anywhere.

What a tab holds stays alive while another is in front. A document is not
opened again when you come back to it; the text editor comes back where you
left it without reading the file again — what you typed and had not saved,
the selection, and the line you were reading, put back before the editor is
shown so the file does not appear at the top and jump. A folder tab comes
back the same way, and is refreshed quietly behind what you were already
looking at.

A tab opened behind is got ready before you reach it: the folder listed, the
document opened in its viewer, the text file read, the shell started. That
is Settings → Preferences → Prepare tabs opened in the background, and it is
capped at three prepared editors at once. While a tab is still working it
shows a spinner where its icon goes, as a browser does — for a document,
until it is really in the editor rather than until the panel exists.

Back and Forward belong to the tab, not to the window: each keeps its own
trail, so going back in one never takes you where another was looking, and
the buttons grey out when that tab has nowhere to go.

The rest of what the strip does: tabs are dragged along the row, duplicated,
kept (pinned to the front as their icon alone, with no cross and immune to
"close them all"), closed one at a time or all at once, and closed by double
click for whoever turns that on. Files and folders dragged out of a listing
can be dropped onto a tab, which moves them into the folder that tab is on —
or copies them with the modifier held. The strip never scrolls: tabs share
the room and stop at a number an administrator sets under Settings → Tabs.

A terminal is a place now. What a terminal *is* came out of the drawer that
showed it, so a page at an address of its own can show one too and there can
be as many shells as there are tabs; the drawer opens per tab, and a new tab
only on the gesture that asks for one.

Closing a tab is the one gesture that destroys what a tab was holding, so a
screen with something to lose can leave a question for its own tab. Asking
it meant the application had to be able to ask at all: `window.confirm`
answers by stopping the world, which a dialog inside the page cannot, so the
question is the application's own dialog and everything from the guard to
the cross on the tab waits for the answer. The browser's boxes are gone from
the rest of the window with it — twenty-three of them, `confirm`, `prompt`
and `alert`, replaced by that dialog and by the notifications this
application already had.

The browser journey comes with it, and with the bench it needs. Most of what
tabs do is what a browser does to a page that is not on screen, and that is
not a question a unit test can ask: a page unmounted the moment another tab
comes forward, a menu clipped away by the strip's own overflow, a shell that
dies when the layout holding it does. Each of those passed its unit suite and
was broken in a browser. So `frontend/e2e/app` holds one journey through the
whole application — set up, sign in, upload, share, delete and restore, read a
version, edit, browse in tabs — run by a second Playwright project against the
real server entry point serving the real build, in a throwaway install the
journey also reads from disk to check what actually landed there. It runs
under `npm run test:e2e` beside the specs already here, and takes about half a
minute.

Two readers in one folder, and the two things they were sharing that they
should not have been.

Where a reader is in a folder has always been remembered against the folder.
With tabs that is no longer enough, because two tabs can be in one folder at
once and be in different places in it — so a tab's place is kept against the
tab as well. It was, and it was then read as a number: `tabPlace(key) ||
get(folder)`. A tab sitting at the top answers nought, nought is false, and
the fallback is the folder's memory that every tab on it shares. Two tabs on
one folder, the first scrolled to the bottom and left there, the second never
moved from the top: the second came back down beside the first. The top is an
answer, so the question is now whether this tab has a place here at all.

The permission to be put back was shared the same way. It is granted for a
folder when somebody walks back up into it — and consumed by whichever tab is
drawn on that folder next, which need not be the tab that was promised it.
One tab walking up out of a subfolder, or coming back out of a file, moved
another reader. The permission names its tab now, and the rule that grants it
moved out of the router's guard into `router/restorePermission.js` so that it
can be asked directly, the way the other rules there are: it had no test, and
what had no test is what went wrong.

The Tabs page sits with the other preferences in the settings list rather
than at the end of it.
Choose two or three text files in a listing, right-click, and Compare. They
open at an address of their own, so a comparison lives in a tab, can be
linked to, and is somewhere you leave and come back to rather than a dialog
to finish.

Every line of both files is there and aligned: two versions of a line sit on
one row, and a gap shows where a side has nothing. Within a changed pair the
part that actually differs is marked, which is what makes one character
different in a long path readable. The alignment is written here rather than
taken from a library because the answer has to be aligned and not merely
listed: a patch says what changed, a comparison has to put line 40 of one
file beside the line it corresponds to in the other.

The differences are counted and numbered, and F8 and F7 walk them — alt with
the arrows does the same, for a keyboard without those keys — coming round
again at the end rather than stopping. Each difference can be taken across in
either direction, a whole run at once, and the file written back keeps the
line endings it had: a comparison that rewrote those would turn a one-line
change into a change on every line.

Three files align on the middle one, which is the arrangement that answers
"who changed what" when two people have both edited a common version. Copies
there go between neighbours, since left and right are not next to each other.

Along the right: a bar the comparison draws for itself, because this
platform draws its own over the content and fades it out when nothing is
moving, and beside it a map of the differences — out of the way until the
pointer nears the edge, pinned from the bar for whoever wants it there. The
map is coloured the way the panes are and from the same single answer about
what a line is: red where a line is only on the left, green where it is only
on the right, amber where both have one and they differ.

Search and replace looks in one file, another, or all of them at once, steps
through what it finds, and replaces one or all — never into a side that is an
earlier version. A line can be edited by hand: double-click it and type, with
Enter breaking the line, backspace at the start joining it to the one above,
the arrows walking from line to line, and a paste of several lines becoming
several lines.

The two sides can be put the other way round, address and tab name with them.
A long file folds down to the differences with a little around them and says
how much it folded, and that is always yours to unfold.

In a tab, the tab is named after the files it compares, so three comparisons
open are three tabs you can tell apart — and coming back to one reads neither
file again: the lines, the difference you were on and anything taken across
and not yet saved are all still there. A comparison with unsaved lines says so
before you close its tab or the window.

Also from the Versions panel: Compare with the file now puts an earlier
version beside the file as it stands, the older one on the left, which is the
order that reads as "what happened since". That side is read-only — there is
nothing to write a version back to.

Everything the text editor can open is comparable. COMPARE_FILE_EXTENSIONS
adds whatever else an installation calls text, because that is a local
question: somebody's .ino, somebody else's .tf.
A pair is a grouping of two tabs, not a split of the window, so there can
be as many pairs as there are tabs and either half can hold anything a tab
can hold: a folder, a document, a shell, a comparison.

What belongs to a place is the pane's own; what belongs to a person stays
one to the window. `files/folderTab.js` drew that line already — this is
the rest of it. `TabPane.vue` is a pane. `paneTab.js` is how a screen on
the far side asks which side it is on: with one pane it answers what
`useRoute()` and "the tab in front" answered before, and with two it
answers for the half the screen is drawn in. Every screen asks it now —
the folder view, the breadcrumb, the selection, a document, an editor, a
comparison, a shell — because each of them had been reading the window,
and the window has only one answer.

The window is the window. `WindowSidebar.vue` and `WindowBar.vue` are what
`BrowserLayout` used to hold, drawn once above every layout so that a pane
can hold anything: a document and a shell are outside that layout on
purpose, and an editor and a comparison are in another one. `sidebar.js`
is a module-level singleton because the toolbar above a pane opens the
same sidebar the window draws. The routes for a document, an editor, a
comparison and the trash are flat for the same reason: a layout that only
passes its children through still breaks the symmetry between the halves,
because the focused pane draws the outermost match and the other draws the
deepest.

`paneBoxes.js` measures where each pane is, for the two things drawn
outside the page that have to sit on one anyway — a document in its viewer
and a shell. Both are told which panes are on screen rather than which tab
is in front, and both take their box from there.

Four faults in the detail of it, each of them one sentence said a
different way:

A pane draws a tab, not an address. Bringing a tab forward is two moves —
the store first, the address bar after — so the pane that has just taken
the focus drew the router's screen for a tick while the router was still
on the address of the half being left. A listing was replaced by a
document and replaced back a frame later, which is why a press in the
other half had to be made twice: the row it was pressed on no longer
existed when the button came up. A pane now keeps its own screen until its
tab and the address agree.

A destroyed pane still names its tab. A page asking "am I still my tab?"
on the way out is asking nothing in a pane that is being taken off screen,
so the editor in the half beside the reader read "the address changed under
me" and threw away where its reader was. It is the tab in front and the
window's own address that say whether anyone navigated.

A place in a folder is a row, not a number of pixels. A pane that comes
back a different width — one of a pair, or a pair becoming one — re-flows
its listing, and the same number is then a different place.

And zero is an answer. A tab left at the top of a folder answers "nought",
which read as "no answer", so the folder's own memory was taken instead —
and that one is shared by every tab on the folder. Three tabs on one
folder, one of them scrolled to the bottom to open a file, and the other
two came back down beside it. Asked as "has this tab a place here" now,
and the permission to be put back names its tab too.

This branch has not been deployed

No deployments
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.

1 participant