Skip to content

feat(windows): add an "Ask me" option for which window a file opened from outside goes to - #1040

Merged
PathGao merged 6 commits into
sftwrdotdev:masterfrom
HellfireXD:feature/ask-which-window-v2
Oct 29, 2026
Merged

PathGao merged 6 commits into
sftwrdotdev:masterfrom
HellfireXD:feature/ask-which-window-v2

Conversation

@HellfireXD

@HellfireXD HellfireXD commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

What this is

The "Ask me" half of #1029, on top of #1038 as @PathGao suggested. Settings

Files gets Open external files in, with two options:

  • Last window: the default, and today's behaviour.
  • Ask me: with more than one window open, a file opened from Explorer,
    Finder, "Open with" or a second launch shows a picker listing the open
    windows. Each is named as the tab's "Move to window" menu names it, with
    its tag colour.

The window Rust delivered the file to, the focused or last focused one, is
the one that asks. It's listed first and selected, so Enter keeps the old
result. The picker brings its window to the front when it opens. Open sends the
files to the chosen window, and Cancel (or Escape, or a click outside)
opens nothing. The keys are the existing dialog's: Enter/y, Escape/n, plus
↑/↓ and Home/End for the list.

A file that some window already has open never reaches the picker. #1038
sends it to that window, so the picker only ever asks about files that are
open nowhere.

The hover hint says it only asks with more than one window open, and that
files opened from inside Markpad always open where you are.

Closes #1029

Mechanism

Rust picks the receiving window and sends it file-path, and the window
loaded the file at once. Now file-path goes through createOpenFilePicker
(src/lib/sessions/openFilePicker.svelte.ts), which loads the file at once
unless all of these hold:

  • the setting is "Ask me"
  • this window doesn't already have the file
  • more than one window is open

Multi-select. Explorer starts one process per selected file, so a
multi-select arrives as separate deliveries a few milliseconds apart. Files
that arrive within 250 ms are asked about in one picker ("Open 3 files in…"),
and any that arrive while it's open join it. I checked this on Windows with 30
files at once, and with 60 where 30 arrived late: all of them landed in one
picker.

Launch files. Since #990 a launch reopens every window, and the launch
files reach the main window before the reopened windows have restored. Asking
then would list windows that haven't registered yet. It would also ask about
files a reopened window is about to take itself (#1038). So nothing is asked
until every window of the launch has restored:

  • Rust broadcasts a new windows-restored event once every open viewer
    window, the launch window included, has asked send_markdown_path. Each
    window asks only after its restore and its first set_window_meta, so by
    then the window list is complete.
  • The check runs again when a window closes, since a window closed
    mid-startup never asks.
  • A window that starts after that point asks windows_restored and doesn't
    wait.
  • Until then, files wait and are asked about together.
  • "Last window", and files this window has open, never wait.
  • If the event never arrives, for example because a reopened window never
    finishes loading, each window stops waiting after 10 seconds.

Keeping the picker in sight. At launch, a reopened window that takes its
own launch files is raised when every window has restored (see below), and
can come up over the launch window, which is the one asking. So the picker
raises its window once, with the existing focusWindow, when it opens.
Clicking another Markpad window while the picker is open still leaves it
behind that window, and clicking back onto the dialog's background cancels
it. That is how every Modal behaves today, so it is left to Modal.

So with "Ask me" set, a launch that reopens several windows and is given
a.md (open in a reopened window) and b.md (open nowhere) brings a.md's
window forward and then asks about b.md alone.

The readiness signal comes from Rust because the frontend can't tell the
launch window apart: windows-are-equal in eslint.config.js forbids it.

Smaller pieces:

  • Modal gains confirmLabel (a named primary button instead of
    Save/Discard), children (content under the message) and onkeydown (sees
    keys first, so the list can take the arrow keys).
  • The picker opens files through documentSession.openFiles, one after
    another, as launch files always were.
  • ViewerWindowEntry gains tag_color, which list_viewer_windows already
    returns.
  • open-files-here is the event that hands a chosen window its files. It's
    declared in windowEvents.ts.
  • bindings.ts is regenerated, with windowsRestored added as a command and
    as an event.

Also fixed: launch files opened behind a reopened window

Found while testing this PR, and it happens with either setting, including
the default.

To reproduce on current master:

  1. Have two windows open, one of them a pinned tag.
  2. Quit with Exit, so both are remembered (feat(windows): reopen every window of the last session #990).
  3. Double-click a file in Explorer that neither window has open.
  4. The file opens in the main window (fix: retain Windows multi-file opens during startup #1001), but the pinned window
    finishes restoring after that and comes up over it, so the file is
    hidden.

The launch files are handed out while the other windows are still being
reopened, and nothing brought the window that took them back to the front
afterwards.

Fix: Rust remembers the last window that took launch files. That's the
launch window, or a reopened window that has one of them open (#1038). When
it sends windows-restored, the moment the last window has finished, it
brings that window to the front. I used that event rather than a delay,
since any delay would be a guess: too short on a slow machine, visible lag
on a fast one.

Checked by hand: with "Last window" set, the main window now ends up in
front showing the file.

Scope

  • No "New window" option. Left for later. It needs its own decision about
    tags.
  • Only files from outside. Opening a file from inside a window (the
    Open dialog, the folder sidebar, a link, Recent Files) already says which
    window it's for, so it never asks.
  • A closed pinned tag isn't listed. The picker lists open windows. Opening
    a pinned tag's window for a file would be a separate decision.

Tests

  • Picker, scripts/windows.spec.ts, 14 new under "a file opened from
    outside asks which window it goes to". Under vitest, so $state is the real
    rune, with every dependency a recording fake:
    • "Last window" and a single window load at once
    • a file open here switches to its tab
    • files wait for markReady, then are asked about together
    • "Last window" and open files don't wait
    • files load in arrival order
    • the receiving window is offered first
    • the window is raised once when it asks, and not when a file just loads
    • a batch, and a late arrival, join one picker
    • confirm here, confirm elsewhere, a window closed meanwhile, cancel
    • the arrow keys, and the keys left to Modal
  • Setting, scripts/settings.spec.ts, 1 new: the default, surviving a
    restart, rejecting an unknown value, and the options matching what the
    store accepts.
  • Rust, window_runtime.rs, 1 new:
    the_launch_is_restored_once_every_open_window_has_asked

I broke the picker in three ways in a copy, and each break failed a test:

  • it never waits
  • no ordering queue
  • it asks with "Last window" set

The wiring is checked by hand, not by a test: the file-path and
windows-restored listeners, the event broadcast, raising the launch files'
window, and the Modal markup.
MarkdownViewer.svelte isn't mounted in the suite.

Verification

  • Locally, in containers matching test.yml:
    • npm run check: 0 errors
    • npm run lint
    • npm test: 946
    • npm run test:vitest: 730
    • cargo fmt --check, and cargo clippy --locked --all-targets -- -D warnings
    • cargo test: 248
  • npm audit fails on the deepmerge advisory (GHSA-gcqx-w48m-w2h4),
    the same as on fix(windows): switch to the window that already has a file instead of opening it twice #1038. It isn't from this change.
  • My fork's Test Build on this branch: Linux, Windows and macOS all green.
  • By hand on Windows 11, with that build:
    • "Ask me", while running, two windows:
      • A file open nowhere shows the picker, with the current window first.
      • Open here, Open in the other window, and Cancel (Escape, the button,
        a background click) each do what they say.
      • A multi-select of 10 files shows one picker, and all of them open in
        the chosen window.
      • A file open in another window switches to it, and a file open here
        switches to its tab, both with no picker.
      • With one window, a file opens directly.
    • "Ask me", at launch, after quitting with Exit:
      • A file open nowhere shows the picker in front of the reopened
        windows.
      • With a.md open in a reopened window, opening a.md and b.md
        together brings that window forward and asks about b.md only.
      • a.md alone goes to its window with no picker.
    • "Last window":
      • A file opens in the last active window, with no picker.
      • At launch, it opens in the main window, which ends up in front.
      • A file open in another window still switches to that window.
    • The setting: the hover hint shows, and "Ask me" survives a restart.
  • Not verified by hand: macOS and Linux. The checks by hand above were
    made before 620411a, which replaced the hold on the asking window with a
    one-time raise. The launch with a.md and b.md hasn't been rechecked by
    hand since.

…from outside goes to

Settings > Files gets "Open external files in": "Last window" (the default,
today's behaviour) or "Ask me". With "Ask me" and more than one window open,
a file opened from Explorer, Finder, "Open with" or a second launch shows a
picker in the window Rust delivered it to, which is offered first. Open sends
the files to the chosen window, and Cancel opens nothing.

A file that some window already has open never reaches the picker: Rust
sends it to that window first (sftwrdotdev#1038). A file this window has open switches
to its tab without asking.

Explorer starts one process per selected file, so files that arrive within
250 ms are asked about together, and files that arrive while the picker is
open join it.

Launch files are asked about too, but only once every window of the launch
has restored, so the list is complete and the files that a reopened window
has open have already gone to it. Rust broadcasts `windows-restored` once
every open window has asked `send_markdown_path`, and `windows_restored`
answers a window that starts later. A window that never finishes loading
would hold the event back, so each window stops waiting after 10 seconds.

`Modal` gains `confirmLabel`, `children` and `onkeydown` for the list and
its arrow keys. The picker opens files one after another through
`openFiles`, as the launch's files always have.

Refs sftwrdotdev#1029

Assisted-By: Claude Opus 5.5
At launch the reopened windows come up over the launch window after Rust
has delivered the launch files to it, so the picker opened hidden behind
them, and the click that brought it forward landed on the dialog's
backdrop and cancelled it.

While its picker is on screen, a window is now held in front of the other
Markpad windows (`hold_front`): it is brought forward when the picker
opens, and a viewer window focused meanwhile hands the focus straight back,
as an app-modal dialog's owner does. Other apps are not affected. The hold
ends when the picker is answered or cancelled, or the window closes.

Refs sftwrdotdev#1029

Assisted-By: Claude Opus 5.5
…ry window has restored

The launch files open while sftwrdotdev#990 is still reopening the other windows, and
a window that finishes restoring later comes up over the one that took
them. With "Last window" set, the file was there but hidden.

Rust now remembers the last window that took launch files (the launch
window, or a reopened window that has one open, sftwrdotdev#1038) and brings it back
to the front when it sends `windows-restored`, which is the moment the last
window has finished. No delay is involved.

Refs sftwrdotdev#1029

Assisted-By: Claude Opus 5.5
@PathGao

PathGao commented Oct 28, 2026

Copy link
Copy Markdown
Collaborator

Thanks, this reads well. The picker logic and the launch-file fix both look right to me, and keeping the launch-file fix in this PR is fine. I ran everything except npm audit locally on 340c0c5 and it all passes.

One question about hold_front: is it still needed now that the third commit is in? windows-restored already brings the window with the launch files to the front, and the picker only shows after that, so at launch it should already be in front without the focus hand-back. While running, the file goes to the focused window, which Rust brings forward anyway.

It also has a side effect: with the picker open in A, opening a file from Explorer that B already has open sends it to B, but B is pushed straight back behind A.

If dropping hold_front (plus asking, release_front, front_instead and the focus hook) still leaves the picker in front at launch on your Windows build, I'd rather drop it.

…anches

request() joins whichever list is collecting the path in one place, and
confirm() opens here unless the selected window is another one that is
still open, so each outcome is written once.
@HellfireXD

Copy link
Copy Markdown
Contributor Author

Thanks for the review and for running the checks. You're right about the B side effect.

The third commit covers launch with a single file, but not a mixed batch. Say a.md is open in a reopened window W, b.md is open nowhere, and both are opened at launch:

  1. The launch window takes b.md and becomes took_files.
  2. W restores later and takes a.md, so took_files becomes W.
  3. On windows-restored, W comes forward, and the picker for b.md opens behind it.

Clicking the launch window to get back to the picker lands on the dialog's background, which cancels it. I hit that in testing with a single file, before adding the hold and before the third commit. The hold also covers clicking another Markpad window while the picker is open, which otherwise leaves it behind that window.

Options I see:

  1. Drop the hold, and raise the window once when the picker opens with the existing commands.focusWindow(selfLabel). hold_front, asking, release_front, front_instead and the focus hook all go, and this fixes the mixed batch and your B case. Clicking another Markpad window still leaves the picker behind it.
  2. Move the picker to whichever Markpad window you focus. Rust tells the asking window to hand its question to the newly focused one, which shows the picker with itself selected. The picker can't be lost behind another window, and files that arrive while it's open join it. In your B case, B comes forward and the picker moves onto it. It needs an event in each direction plus tests, so it's more code than the hold.
  3. Keep the hold, but skip the hand-back when Rust raised a window for a file. This fixes your B case, but it keeps the code you'd like gone and adds a special case.

I'd go with 2, since it keeps the picker in front of you without fighting the window Rust just raised, though it's the most code of the three. Which do you prefer?

@PathGao

PathGao commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Good catch on the mixed batch, I missed that took_files depends on which window finishes restoring last.

Let's go with 1, and I've pushed it myself, so there's nothing for you to change:

  • dce0e02 is a small simplification in openFilePicker.svelte.ts.
  • 620411a drops hold_front, asking, release_front, front_instead and the focus hook. The picker now raises its window once when it opens, with the existing focusWindow. The picker test checks that it raises once when asking and not when a file just loads.

The remaining case, clicking another Markpad window and then clicking back onto the dialog's background, is how every Modal behaves today, including the unsaved-changes prompt. If it's worth fixing, it should be fixed in Modal for all dialogs, in a separate PR.

Everything except npm audit passes locally on 620411a. If you get a chance, a quick check of the mixed-batch launch on your Windows build would help, since I can't run that by hand.

…ront

With a mixed launch batch, a reopened window that takes its own launch
file is raised at `windows-restored` and can cover the launch window's
picker. Raising the asking window when the picker opens covers that.
The hold also pushed back any window Rust had just raised for a file,
so `hold_front` and its focus hand-back go.
@HellfireXD

Copy link
Copy Markdown
Contributor Author

Everything except npm audit passes locally on 620411a. If you get a chance, a quick check of the mixed-batch launch on your Windows build would help, since I can't run that by hand.

It works on windows with the test build from run 40658097535

@PathGao
PathGao merged commit 043417e into sftwrdotdev:master Oct 29, 2026
3 of 4 checks passed
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.

Option to choose which window a file opens in when several windows are open

2 participants