diff --git a/scripts/documents.save.spec.ts b/scripts/documents.save.spec.ts index a6799a70..d6a481d7 100644 --- a/scripts/documents.save.spec.ts +++ b/scripts/documents.save.spec.ts @@ -675,13 +675,18 @@ describe('untitled first save writes', () => { let nextSaveTarget: string | null = null; /** Tabs the session raised the conflict bar for. */ const conflicts: string[] = []; + const errors: string[] = []; + /** Files that exist but fail to read: a permission error, a sharing violation. */ + const unreadable = new Set(); beforeAll(() => { (window as any).__TAURI_INTERNALS__ = { metadata: { currentWindow: { label: 'main' }, currentWebview: { windowLabel: 'main', label: 'main' } }, invoke: (cmd: string, args: any) => { if (cmd === 'canonicalize_path') return Promise.resolve(args.path); + if (cmd === 'path_is_missing') return Promise.resolve(!disk.has(args.path)); if (cmd === 'read_file_content_checked') { + if (unreadable.has(args.path)) return Promise.reject(new Error('Permission denied (os error 13)')); const content = disk.get(args.path); return content === undefined ? Promise.reject(new Error('No such file or directory (os error 2)')) @@ -711,7 +716,7 @@ describe('untitled first save writes', () => { setLoadingTabs: () => {}, measureInitialViewport: () => {}, isScrolling: () => false, - onError: () => {}, + onError: (message: string) => void errors.push(message), onDiskChangedUnderSave: (tabId: string) => void conflicts.push(tabId), cancelPendingAutoSave: () => {}, askClose: async () => 'discard' as const, @@ -728,6 +733,8 @@ describe('untitled first save writes', () => { disk.clear(); writes.length = 0; conflicts.length = 0; + errors.length = 0; + unreadable.clear(); nextSaveTarget = null; } @@ -781,6 +788,130 @@ describe('untitled first save writes', () => { assert.deepEqual(conflicts, [tabId]); assert.equal(disk.get('/notes/a.md'), 'theirs', 'their text is still there'); }); + + // `markpad --edit draft.md` for a file that does not exist yet (#1051). + test('a file a command line names before it exists is created by the first save', async () => { + reset(); + const session = makeSession(); + await session.openMissingFile('/notes/draft.md', 'editor'); + const tab = tabManager.activeTab!; + assert.equal(tab.path, '/notes/draft.md'); + assert.equal(tab.isEditing, true); + assert.equal(tab.isDirty, false, 'closing it untouched creates nothing'); + assert.deepEqual(writes, []); + + tabManager.updateTabRawContent(tab.id, 'draft'); + assert.equal(await session.saveContent(tab.id), true); + assert.equal(disk.get('/notes/draft.md'), 'draft'); + assert.deepEqual(conflicts, []); + + // Created now: a file that goes missing after this is a conflict again. + disk.delete('/notes/draft.md'); + tabManager.updateTabRawContent(tab.id, 'more'); + assert.equal(await session.saveContent(tab.id), false); + assert.deepEqual(conflicts, [tab.id]); + }); + + test('a file somebody else creates that cannot be read is still not overwritten', async () => { + reset(); + const session = makeSession(); + await session.openMissingFile('/notes/draft.md', null); + const tabId = tabManager.activeTabId!; + disk.set('/notes/draft.md', 'theirs'); + unreadable.add('/notes/draft.md'); + tabManager.updateTabRawContent(tabId, 'mine'); + + assert.equal(await session.saveContent(tabId), false); + assert.equal(disk.get('/notes/draft.md'), 'theirs'); + }); + + test('an empty file somebody else creates first is not overwritten either', async () => { + reset(); + const session = makeSession(); + await session.openMissingFile('/notes/draft.md', null); + const tabId = tabManager.activeTabId!; + disk.set('/notes/draft.md', ''); + tabManager.updateTabRawContent(tabId, 'mine'); + + assert.equal(await session.saveContent(tabId), false); + assert.equal(disk.get('/notes/draft.md'), ''); + }); + + test('a tab moved off the file it was to create guards its new file as usual', async () => { + reset(); + const session = makeSession(); + await session.openMissingFile('/notes/draft.md', null); + const tabId = tabManager.activeTabId!; + tabManager.updateTabRawContent(tabId, 'mine'); + nextSaveTarget = '/notes/other.md'; + assert.equal(await session.saveContentAs(), true); + + // Saved As, so the tab is clean on a real file and is remembered. + assert.deepEqual(JSON.parse(tabManager.serializeState()).tabs.map((tab: { path: string }) => tab.path), ['/notes/other.md']); + // That file then disappears: a save is a conflict, not a quiet re-create. + disk.delete('/notes/other.md'); + tabManager.updateTabRawContent(tabId, 'more'); + assert.equal(await session.saveContent(tabId), false); + assert.deepEqual(conflicts, [tabId]); + }); + + test('reading the file a tab was to create ends the exemption', async () => { + reset(); + const session = makeSession(); + await session.openMissingFile('/notes/draft.md', null); + const tabId = tabManager.activeTabId!; + disk.set('/notes/draft.md', 'theirs'); + await session.loadMarkdown('/notes/draft.md'); + tabManager.updateTabRawContent(tabId, 'theirs, edited'); + + assert.equal(await session.saveContent(tabId), true, 'the tab holds their file now'); + assert.equal(disk.get('/notes/draft.md'), 'theirs, edited'); + }); + + test('a tab still to create its file is remembered only once it has text', async () => { + reset(); + const session = makeSession(); + await session.openMissingFile('/notes/draft.md', null); + const saved = () => JSON.parse(tabManager.serializeState()).tabs.map((tab: { path: string }) => tab.path); + assert.deepEqual(saved(), [], 'the next launch would only fail to read it'); + tabManager.updateTabRawContent(tabManager.activeTabId!, 'draft'); + assert.deepEqual(saved(), ['/notes/draft.md']); + }); + + test('the same missing file delivered twice at launch is one quiet tab', async () => { + reset(); + const session = makeSession(); + await session.openMissingFile('/notes/draft.md', 'editor'); + // The second delivery has no command-line request left to take. + await session.openFiles(['/notes/draft.md']); + assert.equal(tabManager.tabs.length, 1); + assert.equal(tabManager.activeTab!.createsFile, '/notes/draft.md'); + assert.deepEqual(errors, []); + }); + + test('a file somebody else creates first is not overwritten by the tab waiting to create it', async () => { + reset(); + const session = makeSession(); + await session.openMissingFile('/notes/draft.md', null); + const tabId = tabManager.activeTabId!; + disk.set('/notes/draft.md', 'theirs'); + tabManager.updateTabRawContent(tabId, 'mine'); + + assert.equal(await session.saveContent(tabId), false); + assert.equal(disk.get('/notes/draft.md'), 'theirs'); + }); + + test('a command line view replaces the setting for the tab it opens', async () => { + reset(); + const session = makeSession(); + settings.openFileMode = 'preview'; + disk.set('/notes/a.md', 'text'); + await session.loadMarkdown('/notes/a.md', { openFileMode: 'editor' }); + assert.equal(tabManager.activeTab!.isEditing, true); + disk.set('/notes/b.md', 'text'); + await session.loadMarkdown('/notes/b.md'); + assert.equal(tabManager.activeTab!.isEditing, false, 'the next file follows the setting again'); + }); }); /* diff --git a/scripts/tabs.spec.ts b/scripts/tabs.spec.ts index ce97d933..bd982091 100644 --- a/scripts/tabs.spec.ts +++ b/scripts/tabs.spec.ts @@ -323,11 +323,19 @@ describe('one tab per file', () => { }); }); +/** What `take_command_line_open` answers, per path: what a command line asked. */ +const commandLine = new Map(); + describe('path identity on a case-folding volume', () => { beforeAll(() => { (window as any).__TAURI_INTERNALS__ = { metadata: { currentWindow: { label: 'main' }, currentWebview: { windowLabel: 'main', label: 'main' } }, invoke: (cmd: string, args: any) => { + if (cmd === 'take_command_line_open') { + const request = commandLine.get(args.path) ?? null; + commandLine.delete(args.path); + return Promise.resolve(request); + } if (cmd === 'canonicalize_path') { const resolved = lookup(args.path); // A name nothing answers to is still a legal target (Save As to a @@ -537,6 +545,63 @@ describe('path identity on a case-folding volume', () => { ); }); + // `markpad --edit a.md b.md c.md` (#1051): a.md is already open in preview, + // b.md is not, c.md does not exist yet. + test('files from a command line open in the view it asked for', async () => { + reset(); + const session = makeSession(); + settings.openFileMode = 'preview'; + disk.set('/notes/a.md', { body: 'a', lossy: false }); + disk.set('/notes/b.md', { body: 'b', lossy: false }); + await session.loadMarkdown('/notes/a.md'); + for (const path of ['/notes/a.md', '/notes/b.md']) commandLine.set(path, { mode: 'editor', missing: false }); + commandLine.set('/notes/c.md', { mode: 'editor', missing: true }); + const switched: string[] = []; + + await session.openFiles(['/notes/a.md', '/notes/b.md', '/notes/c.md'], async (mode) => { + switched.push(`${tabManager.activeTab!.path}:${mode}`); + }); + + assert.deepEqual(switched, ['/notes/a.md:editor'], 'the open tab switches through the view toggle'); + const byPath = (path: string) => tabManager.tabs.find((tab) => tab.path === path)!; + assert.equal(byPath('/notes/b.md').isEditing, true); + assert.equal(byPath('/notes/c.md').isEditing, true); + assert.equal(byPath('/notes/c.md').isDirty, false); + assert.equal(commandLine.size, 0, 'each request is taken once'); + + // The same file from Finder afterwards opens as the setting says. + await session.openFiles(['/notes/a.md'], async (mode) => void switched.push(`again:${mode}`)); + assert.equal(switched.length, 1); + }); + + test('a command line spelling an open file differently still switches its view', async () => { + reset(); + const session = makeSession(); + settings.openFileMode = 'preview'; + disk.set('/notes/A.md', { body: 'a', lossy: false }); + await session.loadMarkdown('/notes/A.md'); + commandLine.set('/notes/a.md', { mode: 'editor', missing: false }); + const switched: string[] = []; + + await session.openFiles(['/notes/a.md'], async (mode) => void switched.push(mode)); + + assert.equal(tabManager.tabs.length, 1); + assert.deepEqual(switched, ['editor']); + }); + + test('a command line naming a missing file that is open switches its view', async () => { + reset(); + const session = makeSession(); + await session.openMissingFile('/notes/new.md', 'editor'); + commandLine.set('/notes/new.md', { mode: 'preview', missing: true }); + const switched: string[] = []; + + await session.openFiles(['/notes/new.md'], async (mode) => void switched.push(mode)); + + assert.equal(tabManager.tabs.length, 1); + assert.deepEqual(switched, ['preview']); + }); + test('a link opened in a new tab goes through the ordinary open path', async () => { reset(); const session = makeSession(); diff --git a/src-tauri/src/app.rs b/src-tauri/src/app.rs index 4673f299..aa1aa308 100644 --- a/src-tauri/src/app.rs +++ b/src-tauri/src/app.rs @@ -85,6 +85,7 @@ pub fn run() { #[cfg(target_os = "macos")] if let tauri::RunEvent::Opened { urls } = _event { for path in opened_file_paths(urls) { + window_runtime::forget_command_line_open(_app_handle, &path); window_runtime::deliver_opened_path(_app_handle, path); } } @@ -150,6 +151,8 @@ fn bindings_builder() -> tauri_specta::Builder { window_runtime::is_window_tag_taken, window_runtime::offer_tab_to_window, window_runtime::focus_window, + window_runtime::take_command_line_open, + window_runtime::path_is_missing, window_runtime::list_pinned_tags, window_runtime::save_pinned_tag, window_runtime::update_pinned_tag, @@ -268,8 +271,11 @@ fn setup(app: &mut tauri::App) -> Result<(), Box>>, /// Launch files routed to the window that already has them open. launch_routing: Mutex, + /// Files named on a command line, with the view it asked for, until a + /// window opens them (`take_command_line_open`). + command_line_files: Mutex>>, +} + +/// The view a command line asks its files to open in: `--preview`, `--edit` +/// or `--split`. Overrides the "Open existing files in" setting for that +/// launch only (#1051). +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, specta::Type)] +#[serde(rename_all = "lowercase")] +pub enum OpenFileMode { + Preview, + Editor, + Split, +} + +/// A file a command line named, as the window opening it needs to know it. +#[derive(serde::Serialize, specta::Type)] +pub struct CommandLineOpen { + mode: Option, + /// No file there yet: open an empty tab that creates it on first save. + missing: bool, } /// Launch files that belong to a window other than the launch window, because @@ -143,6 +165,7 @@ impl AppState { claimed_recovery: Mutex::new(HashSet::new()), open_files: Mutex::new(HashMap::new()), launch_routing: Mutex::new(LaunchRouting::default()), + command_line_files: Mutex::new(HashMap::new()), } } @@ -1350,11 +1373,77 @@ pub fn startup_paths(args: &[String], cwd: &Path) -> Vec { .collect() } +/// The view flag on a command line. The last one wins, as with most CLIs. +pub fn startup_open_mode(args: &[String]) -> Option { + args.iter() + .skip(1) + .rev() + .find_map(|arg| match arg.as_str() { + "--preview" => Some(OpenFileMode::Preview), + "--edit" => Some(OpenFileMode::Editor), + "--split" => Some(OpenFileMode::Split), + _ => None, + }) +} + +/// `startup_paths`, recorded for `take_command_line_open`. A later command +/// line naming the same file replaces what an earlier one asked. +pub fn command_line_paths( + app: &AppHandle, + args: &[String], + cwd: &Path, +) -> Vec { + let paths = startup_paths(args, cwd); + let mode = startup_open_mode(args); + let state = app.state::(); + let mut recorded = lock_recover(&state.command_line_files); + for path in &paths { + recorded.insert(path.clone(), mode); + } + paths +} + +/// Asked once per file a window opens from outside the app. `None` for a file +/// no command line named, which opens as before. +#[tauri::command] +#[specta::specta] +pub async fn take_command_line_open( + state: State<'_, AppState>, + path: String, +) -> Result, String> { + let Some(mode) = lock_recover(&state.command_line_files).remove(&path) else { + return Ok(None); + }; + let missing = crate::commands::blocking(move || Ok(names_no_file(Path::new(&path)))).await?; + Ok(Some(CommandLineOpen { mode, missing })) +} + +/// Forgets what a command line asked for `path`: the OS opened it some other +/// way (a Finder open on macOS), so the request is not this open's. +// Only macOS has open-document events; the other platforms open files by argv. +#[cfg_attr(not(target_os = "macos"), allow(dead_code))] +pub fn forget_command_line_open(app: &AppHandle, path: &str) { + lock_recover(&app.state::().command_line_files).remove(path); +} + +/// True only when the filesystem says nothing is there. A path it cannot +/// read for another reason (permissions, a sharing violation) is not missing. +fn names_no_file(path: &Path) -> bool { + matches!(fs::symlink_metadata(path), Err(e) if e.kind() == std::io::ErrorKind::NotFound) +} + +/// Whether `path` names no file yet. Asked by a save that is to create it. +#[tauri::command] +#[specta::specta] +pub async fn path_is_missing(path: String) -> Result { + crate::commands::blocking(move || Ok(names_no_file(Path::new(&path)))).await +} + pub fn handle_single_instance(app: &AppHandle, args: Vec, cwd: String) { // Explorer launches one process per selected file, so these can arrive // before the launch window listens; `deliver_opened_path` queues them and // fronts the window each one lands in. - let paths = startup_paths(&args, Path::new(&cwd)); + let paths = command_line_paths(app, &args, Path::new(&cwd)); if paths.is_empty() { if let Some(window) = pick_delivery_window(app) { bring_to_front(&window); @@ -1795,6 +1884,75 @@ mod tests { assert_eq!(pending, Some(expected.to_vec())); } + #[test] + fn only_a_path_with_nothing_there_is_missing() { + let dir = std::env::temp_dir().join(format!("markpad-missing-{}", std::process::id())); + fs::create_dir_all(&dir).unwrap(); + let file = dir.join("a.md"); + fs::write(&file, b"x").unwrap(); + assert!(!names_no_file(&file)); + assert!(!names_no_file(&dir)); + assert!(names_no_file(&dir.join("b.md"))); + fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn the_last_view_flag_on_a_command_line_wins() { + let mode = |args: &[&str]| { + startup_open_mode(&args.iter().map(|a| a.to_string()).collect::>()) + }; + assert_eq!(mode(&["markpad", "a.md"]), None); + assert_eq!( + mode(&["markpad", "--edit", "a.md"]), + Some(OpenFileMode::Editor) + ); + assert_eq!( + mode(&["markpad", "a.md", "--split"]), + Some(OpenFileMode::Split) + ); + assert_eq!( + mode(&["markpad", "--edit", "--preview", "a.md"]), + Some(OpenFileMode::Preview) + ); + // argv[0] is the program, never a flag. + assert_eq!(mode(&["--edit", "a.md"]), None); + assert_eq!(mode(&["markpad", "--editor", "a.md"]), None); + } + + // `markpad --edit a.md` with Markpad running: the window that opens a.md + // learns the view from here. A later plain `markpad a.md` asks for none. + #[test] + #[cfg(not(windows))] + fn a_command_line_records_its_view_for_each_file() { + let app = tauri::test::mock_app(); + app.manage(AppState::new()); + let path = Path::new("/n").join("a.md").display().to_string(); + let launch = |args: &[&str]| { + handle_single_instance( + app.handle(), + args.iter().map(|a| a.to_string()).collect(), + "/n".into(), + ) + }; + launch(&["markpad", "--edit", "a.md"]); + assert_eq!( + lock_recover(&app.state::().command_line_files).get(&path), + Some(&Some(OpenFileMode::Editor)) + ); + launch(&["markpad", "a.md"]); + assert_eq!( + lock_recover(&app.state::().command_line_files).get(&path), + Some(&None) + ); + // A Finder open of the same file drops a request nobody took. + launch(&["markpad", "--edit", "a.md"]); + forget_command_line_open(app.handle(), &path); + assert_eq!( + lock_recover(&app.state::().command_line_files).get(&path), + None + ); + } + fn idle_watcher() -> RecommendedWatcher { RecommendedWatcher::new(|_: notify::Result| {}, Config::default()).unwrap() } diff --git a/src/lib/MarkdownViewer.svelte b/src/lib/MarkdownViewer.svelte index 7aa6f248..2fd9c758 100644 --- a/src/lib/MarkdownViewer.svelte +++ b/src/lib/MarkdownViewer.svelte @@ -489,7 +489,7 @@ import { followActiveFile } from './sessions/fileWatch.svelte.js'; setting: () => settings.openFileTarget, isOpenHere: (path) => tabManager.tabs.some((tab) => isSameFilePath(tab, { path })), listWindows: () => commands.listViewerWindows() as Promise, - openHere: (paths) => documentSession.openFiles(paths), + openHere: (paths) => openFromOutside(paths), sendTo: async (label, paths) => { await emitToWindow(label, 'open-files-here', paths); await commands.focusWindow(label); @@ -1066,6 +1066,10 @@ import { followActiveFile } from './sessions/fileWatch.svelte.js'; return documentSession.loadMarkdown(filePath, options); } + function openFromOutside(paths: readonly string[]) { + return documentSession.openFiles(paths, (mode) => viewMode.setViewMode(mode === 'editor' ? 'edit' : mode)); + } + function currentMermaidTheme() { return resolveMermaidTheme({ theme: settings.theme, @@ -2528,7 +2532,7 @@ import { followActiveFile } from './sessions/fileWatch.svelte.js'; mergeSelfInto(event.payload).catch((error) => console.error('Failed to merge window', error)); }], ['open-files-here', async (event) => { - for (const path of event.payload) if (typeof path === 'string' && path) await loadMarkdown(path); + await openFromOutside(event.payload.filter((path: unknown) => typeof path === 'string' && path)); }], ['window-identify', (event) => { identifyFlash = event.payload; diff --git a/src/lib/bindings.ts b/src/lib/bindings.ts index 3388da12..bb203041 100644 --- a/src/lib/bindings.ts +++ b/src/lib/bindings.ts @@ -259,6 +259,13 @@ export const commands = { isWindowTagTaken: (name: string) => __TAURI_INVOKE("is_window_tag_taken", { name }), offerTabToWindow: (targetLabel: string, token: string) => __TAURI_INVOKE("offer_tab_to_window", { targetLabel, token }), focusWindow: (label: string) => __TAURI_INVOKE("focus_window", { label }), + /** + * Asked once per file a window opens from outside the app. `None` for a file + * no command line named, which opens as before. + */ + takeCommandLineOpen: (path: string) => __TAURI_INVOKE("take_command_line_open", { path }), + /** Whether `path` names no file yet. Asked by a save that is to create it. */ + pathIsMissing: (path: string) => __TAURI_INVOKE("path_is_missing", { path }), listPinnedTags: () => __TAURI_INVOKE("list_pinned_tags"), /** `false` when another pin already holds the name. */ savePinnedTag: (name: string, color: string, files: string[]) => __TAURI_INVOKE("save_pinned_tag", { name, color, files }), @@ -351,6 +358,13 @@ export type CancelOutcome = { claimed: boolean, }; +/** A file a command line named, as the window opening it needs to know it. */ +export type CommandLineOpen = { + mode: OpenFileMode | null, + /** No file there yet: open an empty tab that creates it on first save. */ + missing: boolean, +}; + /** `file-changed`: the file this window watches changed on disk. */ export type FileChanged = string; @@ -390,6 +404,13 @@ export type HeadingAnchor = { slug: string, }; +/** + * The view a command line asks its files to open in: `--preview`, `--edit` + * or `--split`. Overrides the "Open existing files in" setting for that + * launch only (#1051). + */ +export type OpenFileMode = "preview" | "editor" | "split"; + /** * The editor's headings and foldable blocks, from one parse: its folding * provider wants both for the same buffer, and parsing a long document is the diff --git a/src/lib/sessions/documentSession.svelte.ts b/src/lib/sessions/documentSession.svelte.ts index 418cb271..118b768c 100644 --- a/src/lib/sessions/documentSession.svelte.ts +++ b/src/lib/sessions/documentSession.svelte.ts @@ -1,11 +1,12 @@ import { commands, UNMAPPABLE_CODE } from '../bindings.js'; import { save } from '@tauri-apps/plugin-dialog'; import { settings } from '../stores/settings.svelte.js'; -import { tabManager, type Tab } from '../stores/tabs.svelte.js'; +import { createsItsFile, tabManager, type Tab } from '../stores/tabs.svelte.js'; import { getMarkdownBodyWithoutFrontMatter } from '../utils/frontMatter.js'; import { t } from '../utils/i18n.js'; import { LIST_MARKER, LIST_MARKER_PREFIX, TASK_BOX } from '../utils/listSyntax.js'; import { hasMarkdownLinkExtension, MARKDOWN_LINK_EXTENSIONS } from '../utils/markdownLinks.js'; +import type { OpenFileMode } from '../utils/openFileMode.js'; import { canonicalizePath, isSameFilePath } from '../utils/pathIdentity.js'; export type LoadMarkdownOptions = { @@ -47,6 +48,8 @@ export type LoadMarkdownOptions = { * its file to the top of the list. */ skipRecentFile?: boolean; + /** What a new tab opens as, in place of the setting: a command line's `--edit` (#1051). */ + openFileMode?: OpenFileMode; }; /** @@ -237,6 +240,9 @@ export function createDocumentSession(options: DocumentSessionOptions) { // same way. The dialog has already asked about replacing an existing // file, which is the question this one would be a worse version of. if (!tab.path) return false; + // A file this tab is still to create: nothing there is what it expects, + // and anything there, even an empty file, is somebody else's. + if (createsItsFile(tab) && path === tab.path) return !(await commands.pathIsMissing(path).catch(() => false)); try { // The same read the reload does, so the two strings are comparable: // decoded with the file's own encoding rather than assumed UTF-8. @@ -586,8 +592,9 @@ export function createDocumentSession(options: DocumentSessionOptions) { // full-read branch below: those panes can write, and an editor // bound to the 5MB preview slice is one keystroke away from // auto-saving it back over the whole file. - tab.isEditing = settings.openFileMode === 'editor'; - if (settings.openFileMode === 'split') tabManager.setSplitEnabled(tab.id, true); + const openFileMode = loadOptions.openFileMode ?? settings.openFileMode; + tab.isEditing = openFileMode === 'editor'; + if (openFileMode === 'split') tabManager.setSplitEnabled(tab.id, true); } const initialIsEditing = tab?.isEditing ?? false; const initialIsSplit = tab?.isSplit ?? false; @@ -702,6 +709,9 @@ export function createDocumentSession(options: DocumentSessionOptions) { if (tab) tab.isEditing = true; tabManager.setTabRawContent(activeId, content); } + // Read from disk, so the file this tab was to create is there now. + const loaded = tabManager.tabs.find((item) => item.id === activeId); + if (loaded) loaded.createsFile = undefined; await options.afterLoad(); if (filePath && !loadOptions.skipRecentFile) options.saveRecentFile(filePath); } catch (error) { @@ -806,6 +816,7 @@ export function createDocumentSession(options: DocumentSessionOptions) { tabManager.updateTabPath(tab.id, targetPath, targetKey); options.saveRecentFile(targetPath); } + tab.createsFile = undefined; // The baseline is the text that reached the file, not the buffer: // anything typed while the write was in flight is still unsaved, // and `isDirty` reads that off these two by itself. @@ -1057,10 +1068,58 @@ export function createDocumentSession(options: DocumentSessionOptions) { return loadMarkdown(filePath, { inNewTab: true }); } - /** Open every path, in order: the files the OS handed the launch window. */ - async function openFiles(filePaths: readonly string[]) { - for (const filePath of filePaths) await loadMarkdown(filePath); + /** + * A command line named a file that does not exist yet (#1051): an empty tab + * at that path, as `vim` and `code` give. The first save creates the file, + * and closing the tab untouched leaves nothing behind. + */ + async function openMissingFile(filePath: string, mode: OpenFileMode | null) { + options.setShowHome(false); + const pathKey = await canonicalizePath(filePath); + const existing = tabManager.tabs.some((tab) => isSameFilePath(tab, { path: filePath, pathKey })); + tabManager.addTab(filePath, '', pathKey); + const tab = tabManager.activeTab; + if (tab && !existing) { + tab.createsFile = filePath; + const openFileMode = !hasMarkdownLinkExtension(filePath) + ? 'editor' + : (mode ?? (settings.newFileDefaultMode ? 'editor' : 'preview')); + tab.isEditing = openFileMode === 'editor'; + if (openFileMode === 'split') tabManager.setSplitEnabled(tab.id, true); + } + await options.afterLoad(); + } + + /** + * Files the OS or another window handed this one, in order. A file named on + * a command line carries the view it asked for (`--edit`, #1051), which a + * tab already open on it switches to through `setViewMode`, and may not + * exist yet. + */ + async function openFiles(filePaths: readonly string[], setViewMode?: (mode: OpenFileMode) => Promise) { + for (const filePath of filePaths) { + const request = await commands.takeCommandLineOpen(filePath).catch(() => null); + const mode = request?.mode ?? undefined; + const pathKey = await canonicalizePath(filePath); + const open = tabManager.tabs.find((tab) => isSameFilePath(tab, { path: filePath, pathKey })); + // A file with nothing on disk to read: one a tab is still to create + // (a launch can deliver one path twice, `send_markdown_path` and + // `file-path`, and the second has no request left), or one a command + // line named that is gone. Reading it would only report it missing. + if (open && (request?.missing || createsItsFile(open))) { + options.setShowHome(false); + tabManager.setActive(open.id); + if (mode) await setViewMode?.(mode); + continue; + } + if (request?.missing) { + await openMissingFile(filePath, request.mode); + continue; + } + await loadMarkdown(filePath, { openFileMode: mode }); + if (open && mode) await setViewMode?.(mode); + } } - return { loadMarkdown, openInNewTab, openFiles, readRestoredTab, readActiveIfUnread, saveContent, saveContentAs, renameFile, toggleTaskCheckbox, allowOverwriteOnce, resolveExternalChange, dirtyTabsChangedOnDisk, ensureFullContent, canCloseTab, isLossySaveRefused }; + return { loadMarkdown, openInNewTab, openFiles, openMissingFile, readRestoredTab, readActiveIfUnread, saveContent, saveContentAs, renameFile, toggleTaskCheckbox, allowOverwriteOnce, resolveExternalChange, dirtyTabsChangedOnDisk, ensureFullContent, canCloseTab, isLossySaveRefused }; } diff --git a/src/lib/stores/tabs.svelte.ts b/src/lib/stores/tabs.svelte.ts index c9484b39..1034fdce 100644 --- a/src/lib/stores/tabs.svelte.ts +++ b/src/lib/stores/tabs.svelte.ts @@ -18,6 +18,11 @@ import { replaceCurrentHistoryEntry, } from '../utils/tabHistory.js'; +/** The tab is on a path a command line named, and has yet to create the file there. */ +export function createsItsFile(tab: Pick): boolean { + return tab.createsFile === tab.path; +} + export interface Tab { id: string; path: string; @@ -95,6 +100,14 @@ export interface Tab { content: string; rawContent: string; originalContent: string; + /** + * A path a command line named before any file was there (#1051), which + * this tab's first save is to create. Only while it is still `path`: Save + * As or a link moves the tab off it, and a save or a read clears it. Not + * persisted: an untouched one is left out of the snapshot, and an edited + * one restored or moved to another window asks once on its first save. + */ + createsFile?: string; /** Preview pixels — the coarsest of three position fields. See `scrollPercentage`. */ scrollTop: number; /** @@ -441,7 +454,9 @@ class TabManager { windowTag: this.windowTag, activeTabId: this.activeTabId, tabs: this.tabs - .filter((t) => hasRealFilePath(t.path)) + // A tab still to create its file, untouched, has nothing to restore: + // the next launch would only fail to read it. + .filter((t) => hasRealFilePath(t.path) && !(createsItsFile(t) && !t.isDirty)) .map((t) => ({ id: t.id, path: t.path,