Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 132 additions & 1 deletion scripts/documents.save.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();

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)'))
Expand Down Expand Up @@ -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,
Expand All @@ -728,6 +733,8 @@ describe('untitled first save writes', () => {
disk.clear();
writes.length = 0;
conflicts.length = 0;
errors.length = 0;
unreadable.clear();
nextSaveTarget = null;
}

Expand Down Expand Up @@ -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');
});
});

/*
Expand Down
65 changes: 65 additions & 0 deletions scripts/tabs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { mode: 'preview' | 'editor' | 'split' | null; missing: boolean }>();

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
Expand Down Expand Up @@ -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();
Expand Down
10 changes: 8 additions & 2 deletions src-tauri/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down Expand Up @@ -150,6 +151,8 @@ fn bindings_builder() -> tauri_specta::Builder<tauri::Wry> {
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,
Expand Down Expand Up @@ -268,8 +271,11 @@ fn setup<R: Runtime>(app: &mut tauri::App<R>) -> Result<(), Box<dyn std::error::
// Not sent here when a window the launch reopens is restoring it: then
// it belongs in that window, and `send_markdown_path` routes it there
// (#1029). Sending it here as well opened a second copy in this one.
let startup_files =
window_runtime::startup_paths(&args, &std::env::current_dir().unwrap_or_default());
let startup_files = window_runtime::command_line_paths(
app.handle(),
&args,
&std::env::current_dir().unwrap_or_default(),
);

if let Some(path) = startup_files
.first()
Expand Down
Loading
Loading