fm84 0.39.0

A synthwave dual-pane TUI file manager
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
//! The dialogs that change files - rename, create, copy, move, link, delete -
//! and Shift+F3, which picks two files to compare.

use super::*;
use crate::fs_ops::PackFormat;

/// F2 on the entry under the cursor, typed into in place of its name.
pub(super) fn open_rename(app_state: &mut AppState) {
    if app_state.refuse_in_archive(app_state.is_left_active) {
        return;
    }
    let children = if app_state.is_left_active { &app_state.children_left } else { &app_state.children_right };
    let state = if app_state.is_left_active { &app_state.state_left } else { &app_state.state_right };
    let Some(item) = state.selected().and_then(|index| children.get(index)) else {
        return;
    };

    // Don't rename the parent entry - ".." resolves to the parent directory itself.
    if item.is_parent() {
        return;
    }

    let mut input = TextInput::new();
    input.set(item.name_full.clone());
    app_state.dialog = Some(Dialog::Rename(input));
}

/// One dialog serves both: F7 makes a directory, Shift+F4 an empty file.
pub(super) fn toggle_create(app_state: &mut AppState, is_dir: bool) {
    // The same key closes it again; the other one switches what is being made.
    if matches!(app_state.dialog, Some(Dialog::Create { is_dir: making, .. }) if making == is_dir) {
        app_state.dialog = None;
        return;
    }
    if app_state.refuse_in_archive(app_state.is_left_active) {
        return;
    }
    app_state.dialog = Some(Dialog::Create { is_dir, input: TextInput::new() });
}

pub(super) fn handle_rename(app_state: &mut AppState) {
    let Some(Dialog::Rename(input)) = app_state.dialog.take() else {
        return;
    };
    let parent_path = if app_state.is_left_active { &app_state.dir_left } else { &app_state.dir_right };
    let children = if app_state.is_left_active { &app_state.children_left } else { &app_state.children_right };
    let state = if app_state.is_left_active { &app_state.state_left } else { &app_state.state_right };
    let Some(item) = state.selected().and_then(|index| children.get(index)) else {
        return;
    };

    // ".." resolves to the parent directory - never rename through it.
    if item.is_parent() {
        return;
    }

    let new_name = input.text;
    // The field opened on the name as shown, which for a name that is not
    // valid UTF-8 is not the name itself. Left as it was, it would rename
    // the file to its own lossy reading.
    // Nothing to rename to: treat it as a cancel, the way F7 treats an empty
    // name. Composing it would point at the parent directory instead.
    if new_name == item.name_full || new_name.is_empty() {
        return;
    }
    // A rename stays in its directory; moving is F6.
    if !is_plain_name(&new_name) {
        app_state.display_error(format!("Not a plain file name: {}", new_name));
        return;
    }

    let original_path = item.path_in(parent_path);
    let new_path = parent_path.join(&new_name);

    // rename() replaces the destination without a word. Equal paths mean the
    // name was left alone, which is a no-op rather than a collision - and so
    // does the same entry under another case, which on a case-insensitive
    // filesystem is how "readme" becomes "README".
    if new_path != original_path && path_exists(&new_path) && !is_same_entry(&original_path, &new_path) {
        app_state.display_error(format!("Already exists: {}", new_name));
        return;
    }

    match rename_path(original_path.clone(), new_path.clone()) {
        Ok(_) => {
            // A directory's size is kept by its path, which just changed.
            if let Some(size) = app_state.dir_sizes.remove(&original_path) {
                app_state.dir_sizes.insert(new_path, size);
            }
            app_state.reload_panel(app_state.is_left_active, Some(new_name.as_ref()));
        }
        Err(e) => app_state.display_error(e.to_string()),
    }
}

/// F8 or Delete, to the trash unless F11 says otherwise, and Shift+F8 or
/// Shift+Delete for good. That one always asks first, whatever Confirm delete
/// is set to: there is no getting it back.
pub(super) fn open_delete(app_state: &mut AppState, permanently: bool) {
    let items = chosen_items(app_state);
    if items.is_empty() || app_state.refuse_in_archive(app_state.is_left_active) {
        return;
    }
    let to_trash = app_state.options.delete_to_trash && !permanently;
    app_state.dialog = Some(Dialog::Delete { items, to_trash });
    if !app_state.options.confirm_delete && !permanently {
        handle_delete_confirm(app_state);
    }
}

pub(super) fn handle_delete_confirm(app_state: &mut AppState) {
    // The removals, the panel reload and the selections are all handled by the
    // job as it finishes, the same as a copy or a move.
    if let Some(Dialog::Delete { items, to_trash }) = app_state.dialog.take() {
        if to_trash {
            app_state.start_trash(items);
        } else {
            app_state.start_delete(items);
        }
    }
}

pub(super) fn handle_create_confirm(app_state: &mut AppState) {
    let Some(Dialog::Create { is_dir, input }) = app_state.dialog.take() else {
        return;
    };
    let name = input.text;
    if name.is_empty() {
        return;
    }

    // Made in the directory on show, not in one named by the path typed.
    if !is_plain_name(&name) {
        app_state.display_error(format!("Not a plain file name: {}", name));
        return;
    }

    let parent_path = if app_state.is_left_active { &app_state.dir_left } else { &app_state.dir_right };
    let path = parent_path.join(&name);
    let result = if is_dir { create_directory(path) } else { create_file(path) };
    match result {
        Ok(_) => app_state.reload_panel(app_state.is_left_active, Some(name.as_ref())),
        Err(e) => app_state.display_error(e.to_string()),
    }
}

/// F5 or F6: copy or move what is chosen, to where the dialog is told. For a
/// single file it opens on that file's path in the other panel, ready to be
/// given another name; for anything else - several entries, or a directory -
/// on the other panel's directory. Enter alone does what it always did. With
/// Confirm copy and move off there is no dialog, and it goes straight there.
pub(super) fn open_transfer(app_state: &mut AppState, is_copy: bool) {
    let sources = chosen_items(app_state);
    if sources.is_empty() {
        return;
    }
    // Out of an archive is a copy; a move would take entries out of it.
    if !is_copy && app_state.refuse_in_archive(app_state.is_left_active) {
        return;
    }
    // Where the other panel is going, should it be on its way somewhere.
    let other = app_state.panel_dir(!app_state.is_left_active);
    if !app_state.options.confirm_copy_move {
        go_transfer(app_state, &sources, &other, true, is_copy);
        return;
    }
    let (default, into_dir) = match &sources[..] {
        [(file, false)] => (other.join(file.file_name().unwrap_or_default()), false),
        _ => (other, true),
    };
    // A directory gets a separator on the end, which says it is one and
    // leaves the cursor where a name would be typed.
    let mut opened_with = default.to_string_lossy().into_owned();
    if into_dir && !opened_with.ends_with(std::path::is_separator) {
        opened_with.push(std::path::MAIN_SEPARATOR);
    }
    let mut input = TextInput::new();
    input.set(opened_with.clone());
    app_state.dialog = Some(Dialog::Transfer { sources, is_copy, input, opened_with, default });
}

/// Enter on the copy or move dialog.
pub(super) fn handle_transfer_confirm(app_state: &mut AppState) {
    let Some(Dialog::Transfer { sources, is_copy, input, opened_with, default }) = app_state.dialog.take() else {
        return;
    };
    // As it opened: the file to exactly that path - so one in the way is
    // asked about, or refused if it is a directory, as F5 always did - and
    // anything else into that directory.
    if input.text == opened_with {
        match &sources[..] {
            [(file, false)] if !opened_with.ends_with(std::path::is_separator) => submit_transfer(app_state, vec![(file.clone(), default, false)], is_copy),
            _ => go_transfer(app_state, &sources, &default, true, is_copy),
        }
        return;
    }
    // Nothing to copy to: taken as a cancel, as an empty name is by F2 and F7.
    if input.text.trim().is_empty() {
        return;
    }
    let base = if app_state.is_left_active { &app_state.dir_left } else { &app_state.dir_right };
    let destination = typed_path(&input.text, base);
    go_transfer(app_state, &sources, &destination, input.text.ends_with(std::path::is_separator), is_copy);
}

/// Copy or move `sources` to `destination`, read as transfer_targets reads it.
fn go_transfer(app_state: &mut AppState, sources: &[(PathBuf, bool)], destination: &Path, names_dir: bool, is_copy: bool) {
    // Into an archive: the other panel inside one, or a path typed through
    // one. Asked of the disk, as transfer_targets asks it.
    if crate::archive::locate(destination).is_some() {
        app_state.display_error(READ_ONLY.to_string());
        return;
    }
    match transfer_targets(sources, destination, names_dir) {
        Ok(items) => submit_transfer(app_state, items, is_copy),
        Err(message) => app_state.display_error(message),
    }
}

/// Start a confirmed copy or move. Every destination is checked before
/// anything is written, for what no answer could make safe - an item onto
/// itself, a directory into itself, a file and a directory in each other's
/// place - since bailing out partway would leave some items done and the rest
/// not. Files already there go by F11's "When destination exists": asked
/// about one at a time as the job reaches them, written over, or - for Refuse
/// - the whole job refused before it starts.
fn submit_transfer(app_state: &mut AppState, items: Vec<(PathBuf, PathBuf, bool)>, is_copy: bool) {
    // Into an archive, whichever way the destination was arrived at.
    if items.iter().any(|(_, dest, _)| dest.parent().is_some_and(|dir| crate::archive::locate(dir).is_some())) {
        return app_state.display_error(READ_ONLY.to_string());
    }
    if let Some(archive) = app_state.panel_archive(app_state.is_left_active).cloned() {
        return submit_extract(app_state, archive, items);
    }
    let taken = match check_destinations(&items) {
        Ok(taken) => taken,
        Err(message) => {
            app_state.display_error(message);
            return;
        }
    };
    // The work itself, the panel reload and the selections are all handled
    // by the job as it finishes.
    match app_state.options.on_existing {
        OnExisting::Refuse if !taken.is_empty() => app_state.display_error(format!("Destination already exists: {}", taken[0].display())),
        OnExisting::Overwrite => app_state.start_transfer(items, is_copy, Clashes::ReplaceAll),
        OnExisting::KeepBoth => app_state.start_transfer(items, is_copy, Clashes::KeepBothAll),
        OnExisting::Ask | OnExisting::Refuse => app_state.start_transfer(items, is_copy, Clashes::Ask),
    }
}

/// F5 from a panel inside an archive: copy the entries out. What no answer
/// could make safe is refused before anything is written, as for a copy - a
/// file and a directory in each other's place - going by what the archive
/// says each entry is, since none of them is on the disk to be asked.
fn submit_extract(app_state: &mut AppState, archive: PathBuf, items: Vec<(PathBuf, PathBuf, bool)>) {
    let mut taken = Vec::new();
    let mut extracting = Vec::new();
    for (source, dest, is_dir) in items {
        if let Ok(metadata) = dest.symlink_metadata() {
            match (is_dir, metadata.is_dir()) {
                (false, true) => return app_state.display_error(format!("Cannot replace directory {} with a file", dest.display())),
                (true, false) => return app_state.display_error(format!("Cannot replace file {} with a directory", dest.display())),
                _ => taken.push(dest.clone()),
            }
        }
        let Ok(inner) = source.strip_prefix(&archive) else {
            continue;
        };
        extracting.push((inner.to_path_buf(), dest));
    }
    match app_state.options.on_existing {
        OnExisting::Refuse if !taken.is_empty() => app_state.display_error(format!("Destination already exists: {}", taken[0].display())),
        OnExisting::Overwrite => app_state.start_extract(archive, extracting, Clashes::ReplaceAll, None),
        OnExisting::KeepBoth => app_state.start_extract(archive, extracting, Clashes::KeepBothAll, None),
        OnExisting::Ask | OnExisting::Refuse => app_state.start_extract(archive, extracting, Clashes::Ask, None),
    }
}

/// Alt+F9: the whole of an archive - the one under the cursor, or the one
/// the panel is inside - extracted to where the dialog is told, opening on
/// the other panel's directory. Without the dialog, when F11 turns off
/// confirming copies, straight there.
pub(super) fn open_extract_all(app_state: &mut AppState) {
    let is_left = app_state.is_left_active;
    let archive = match app_state.panel_archive(is_left) {
        Some(archive) => archive.clone(),
        None => {
            let (children, state, dir) = if is_left {
                (&app_state.children_left, &app_state.state_left, &app_state.dir_left)
            } else {
                (&app_state.children_right, &app_state.state_right, &app_state.dir_right)
            };
            let item = state.selected().and_then(|index| children.get(index));
            match item.map(|item| item.path_in(dir)).filter(|path| item.is_some_and(|item| !item.is_dir) && crate::archive::kind_of(path).is_some()) {
                Some(path) => path,
                None => return app_state.display_error("Put the cursor on an archive to extract it".to_string()),
            }
        }
    };
    let other = app_state.panel_dir(!is_left);
    if !app_state.options.confirm_copy_move {
        return go_extract_all(app_state, archive, &other);
    }
    let mut text = other.to_string_lossy().into_owned();
    if !text.ends_with(std::path::is_separator) {
        text.push(std::path::MAIN_SEPARATOR);
    }
    let mut input = TextInput::new();
    input.set(text);
    app_state.dialog = Some(Dialog::Extract { archive, input });
}

/// Enter on the extract dialog. A path that is not absolute is from the
/// directory the panel is in - for one inside an archive, the directory the
/// archive is in.
pub(super) fn handle_extract_confirm(app_state: &mut AppState) {
    let Some(Dialog::Extract { archive, input }) = app_state.dialog.take() else {
        return;
    };
    if input.text.trim().is_empty() {
        return;
    }
    let base = app_state.panel_real_dir(app_state.is_left_active);
    let destination = typed_path(&input.text, &base);
    go_extract_all(app_state, archive, &destination);
}

/// Everything in `archive`, into `destination`, which has to be there. Each
/// file in the way is asked about as the job reaches it, or written over as
/// F11 says: the archive is not read here, so nothing can be checked ahead.
fn go_extract_all(app_state: &mut AppState, archive: PathBuf, destination: &Path) {
    if crate::archive::locate(destination).is_some() {
        return app_state.display_error(READ_ONLY.to_string());
    }
    if !destination.is_dir() {
        return app_state.display_error(format!("No such directory: {}", destination.display()));
    }
    let clashes = match app_state.options.on_existing {
        OnExisting::Overwrite => Clashes::ReplaceAll,
        OnExisting::KeepBoth => Clashes::KeepBothAll,
        OnExisting::Ask | OnExisting::Refuse => Clashes::Ask,
    };
    app_state.start_extract(archive, vec![(PathBuf::new(), destination.to_path_buf())], clashes, None);
}

/// Alt+P: pack what is chosen into an archive, asking where - opening on the
/// other panel's directory with a name from what is packed: a file's without
/// its ending, a directory's, or for several the name of the directory they
/// are in. A zip unless Tab says otherwise.
pub(super) fn open_pack(app_state: &mut AppState) {
    let is_left = app_state.is_left_active;
    if app_state.panel_archive(is_left).is_some() {
        return app_state.display_error("Copy it out of the archive first: what is packed has to be on the disk".to_string());
    }
    let sources = chosen_items(app_state);
    if sources.is_empty() {
        return;
    }
    let base = match &sources[..] {
        [(path, true)] => path.file_name().map(std::ffi::OsStr::to_os_string),
        [(path, false)] => path.file_stem().map(std::ffi::OsStr::to_os_string),
        _ => app_state.panel_dir(is_left).file_name().map(std::ffi::OsStr::to_os_string),
    };
    let base = base.map_or_else(|| "archive".to_string(), |name| name.to_string_lossy().into_owned());
    let name = format!("{base}{}", PackFormat::Zip.extension());
    let mut input = TextInput::new();
    input.set(app_state.panel_dir(!is_left).join(name).to_string_lossy().into_owned());
    app_state.dialog = Some(Dialog::Pack { sources, input });
}

/// Tab on the pack dialog: the next format, by the ending of the name.
pub(super) fn cycle_pack_format(app_state: &mut AppState) {
    let Some(Dialog::Pack { input, .. }) = &mut app_state.dialog else {
        return;
    };
    input.set(next_pack_format(&input.text));
}

/// `name` with the ending of the format after the one it has - longest
/// ending first, so `.tar.gz` is not taken for `.gz` on a `.tar` - or a zip's
/// for one with none.
pub(super) fn next_pack_format(name: &str) -> String {
    // The front of the name with `ending` taken off it, in any case.
    let without = |ending: &str| {
        let at = name.len().checked_sub(ending.len())?;
        (name.is_char_boundary(at) && name[at..].eq_ignore_ascii_case(ending)).then(|| &name[..at])
    };
    let mut known: Vec<(usize, PackFormat)> = PackFormat::ALL.iter().copied().enumerate().collect();
    known.sort_by_key(|(_, format)| std::cmp::Reverse(format.extension().len()));
    match known.into_iter().find_map(|(at, format)| without(format.extension()).map(|front| (at, front))) {
        Some((at, front)) => format!("{front}{}", PackFormat::ALL[(at + 1) % PackFormat::ALL.len()].extension()),
        None => format!("{name}{}", PackFormat::Zip.extension()),
    }
}

/// Enter on the pack dialog. A name that is not absolute is from the
/// active panel's directory. What is already there is written over only
/// when F11 says to overwrite: one archive in place of another is not asked
/// about a file at a time, as a copy is.
pub(super) fn handle_pack_confirm(app_state: &mut AppState) {
    let Some(Dialog::Pack { sources, input }) = app_state.dialog.take() else {
        return;
    };
    if input.text.trim().is_empty() {
        return;
    }
    let dest = typed_path(&input.text, &app_state.panel_dir(app_state.is_left_active));
    let Some(format) = PackFormat::of(&dest) else {
        let endings: Vec<&str> = PackFormat::ALL.iter().map(|format| format.extension()).collect();
        return app_state.display_error(format!("Name it as an archive, ending in {}", endings.join(", ")));
    };
    match dest.parent() {
        Some(dir) if crate::archive::locate(dir).is_some() => return app_state.display_error(READ_ONLY.to_string()),
        Some(dir) if dir.is_dir() => {}
        Some(dir) => return app_state.display_error(format!("No such directory: {}", dir.display())),
        None => return app_state.display_error(format!("Cannot pack into {}", dest.display())),
    }
    let (dest, replacing) = match dest.symlink_metadata() {
        Ok(metadata) if metadata.is_dir() => return app_state.display_error(format!("{} is a directory", dest.display())),
        Ok(_) if app_state.options.on_existing == OnExisting::Overwrite => (dest, true),
        // Beside it, as a copy keeps both.
        Ok(_) if app_state.options.on_existing == OnExisting::KeepBoth => (crate::fs_ops::free_name(&dest), false),
        Ok(_) => return app_state.display_error(format!("Already exists: {} - another name, or Overwrite or Keep both under F11", dest.display())),
        Err(_) => (dest, false),
    };
    app_state.start_pack(sources, dest, format, replacing);
}

/// Alt+H: the checksums of what is chosen - SHA-256 to start with - or, on
/// a checksum file alone, the files it names checked against it.
pub(super) fn open_checksums(app_state: &mut AppState) {
    let is_left = app_state.is_left_active;
    if app_state.panel_archive(is_left).is_some() {
        return app_state.display_error("Copy it out of the archive first: checksums are worked out of files on the disk".to_string());
    }
    let sources = chosen_items(app_state);
    if let [(path, false)] = &sources[..]
        && crate::checksum::is_sums_file(path)
    {
        return app_state.start_check(path.clone());
    }
    if !sources.is_empty() {
        let base = app_state.panel_dir(is_left);
        app_state.start_checksums(sources, base);
    }
}

/// Keys on the checksum report: scrolling, C to copy, Enter to write a
/// checksum file, Tab or Right for the next algorithm and Shift+Tab or Left
/// for the one before - all worked out already, so the list changes in
/// place - and Esc to close.
pub(super) fn checksums_key(app_state: &mut AppState, code: KeyCode) {
    let Some(Dialog::Checksums(view)) = &mut app_state.dialog else {
        return;
    };
    match code {
        KeyCode::Up => view.scroll = view.scroll.saturating_sub(1),
        KeyCode::Down => view.scroll += 1,
        KeyCode::PageUp => view.scroll = view.scroll.saturating_sub(view.room),
        KeyCode::PageDown => view.scroll += view.room,
        KeyCode::Home => view.scroll = 0,
        // Held to what there is when it is drawn.
        KeyCode::End => view.scroll = usize::MAX,
        KeyCode::Char('c' | 'C') => {
            let text = view.report.to_copy();
            crate::utils::set_system_clipboard(&text);
            app_state.find_note = Some(if text.contains('\n') { "Copied the report".to_string() } else { format!("Copied: {text}") });
            app_state.clipboard = text;
        }

        KeyCode::Tab | KeyCode::Right => view.report.step(true),
        KeyCode::BackTab | KeyCode::Left => view.report.step(false),
        // Enter writes what was worked out; a check has nothing to write,
        // and Enter closes it as Esc does.
        KeyCode::Enter if view.report.shown().is_some() => write_checksums(app_state),
        KeyCode::Esc | KeyCode::Enter => app_state.dialog = None,
        _ => {}
    }
}

/// Enter on the checksum report: the checksum file written beside what it is
/// of, as sha256sum would write it, and landed on. What is already at its
/// name goes by F11's When destination exists, as Pack goes by it.
fn write_checksums(app_state: &mut AppState) {
    let Some(Dialog::Checksums(view)) = &app_state.dialog else {
        return;
    };
    let Some((dest, text)) = view.report.sums_file() else {
        return app_state.display_error("Nothing to write: no checksum could be worked out".to_string());
    };
    let (dest, replacing) = match dest.symlink_metadata() {
        Err(_) => (dest, false),
        Ok(_) if app_state.options.on_existing == OnExisting::Overwrite => (dest, true),
        Ok(_) if app_state.options.on_existing == OnExisting::KeepBoth => (crate::fs_ops::free_name(&dest), false),
        Ok(_) => return app_state.display_error(format!("Already exists: {} - Overwrite or Keep both under F11", dest.display())),
    };
    if let Err(e) = crate::fs_ops::write_whole(&dest, &text, replacing) {
        return app_state.display_error(format!("Cannot write {}: {e}", dest.display()));
    }
    app_state.dialog = None;
    for is_left in [true, false] {
        if dest.parent() == Some(app_state.panel_dir(is_left).as_path()) {
            app_state.reload_panel(is_left, dest.file_name());
        }
    }
    app_state.find_note = Some(format!("Written: {}", dest.file_name().unwrap_or_default().to_string_lossy()));
}

/// Shift+F3: which two files to compare. Two files selected in the panel you
/// are in, in the order it lists them; otherwise the file under the cursor,
/// against the one of the same name in the other panel, or failing that the
/// one under the cursor there. Each side of the comparison is the panel its
/// file came from.
pub(super) fn open_compare(app_state: &mut AppState) {
    let is_left = app_state.is_left_active;
    let file_at_cursor = |app_state: &AppState, is_left: bool| {
        let (children, state, dir) = if is_left {
            (&app_state.children_left, &app_state.state_left, &app_state.dir_left)
        } else {
            (&app_state.children_right, &app_state.state_right, &app_state.dir_right)
        };
        state.selected().and_then(|index| children.get(index)).filter(|item| !item.is_dir).map(|item| (item.name_os.clone(), item.path_in(dir)))
    };

    let (children, selected, dir) = if is_left {
        (&app_state.children_left, &app_state.selected_left, &app_state.dir_left)
    } else {
        (&app_state.children_right, &app_state.selected_right, &app_state.dir_right)
    };
    let selected_files: Vec<PathBuf> = children.iter().filter(|item| !item.is_dir && selected.contains(&item.name_os)).map(|item| item.path_in(dir)).collect();
    if let [first, second] = &selected_files[..] {
        app_state.compare_anywhere(first.clone(), second.clone());
        return;
    }

    let Some((name, here)) = file_at_cursor(app_state, is_left) else {
        app_state.display_error("Put the cursor on a file to compare, or select two".to_string());
        return;
    };
    let (other_children, other_dir) = if is_left { (&app_state.children_right, &app_state.dir_right) } else { (&app_state.children_left, &app_state.dir_left) };
    let namesake = other_children.iter().find(|item| !item.is_dir && item.name_os == name).map(|item| item.path_in(other_dir));
    let Some(there) = namesake.or_else(|| file_at_cursor(app_state, !is_left).map(|(_, path)| path)) else {
        app_state.display_error("Nothing to compare with: the other panel has no file of that name, nor one under its cursor".to_string());
        return;
    };
    if is_left {
        app_state.compare_anywhere(here, there);
    } else {
        app_state.compare_anywhere(there, here);
    }
}

/// Ctrl+Shift+F5: a symbolic link to what is chosen, or with Tab a hard
/// link. For a single entry, file or directory, it opens on that name in the
/// other panel, ready to be given another; for several, on the other panel's
/// directory, a link in it for each under its own name. Always asked,
/// whatever F11 says about confirming copies: there is no destination to
/// take for granted.
pub(super) fn open_symlink(app_state: &mut AppState) {
    let sources = chosen_items(app_state);
    // A link to an entry of an archive would point at nothing on the disk.
    if sources.is_empty() || app_state.refuse_in_archive(app_state.is_left_active) {
        return;
    }
    let other = app_state.panel_dir(!app_state.is_left_active);
    let (default, into_dir) = match &sources[..] {
        [(source, _)] => (other.join(source.file_name().unwrap_or_default()), false),
        _ => (other, true),
    };
    let mut opened_with = default.to_string_lossy().into_owned();
    if into_dir && !opened_with.ends_with(std::path::is_separator) {
        opened_with.push(std::path::MAIN_SEPARATOR);
    }
    let mut input = TextInput::new();
    input.set(opened_with.clone());
    app_state.dialog = Some(Dialog::Symlink {
        sources,
        input,
        opened_with,
        default,
        hard: false,
    });
}

/// Enter on the symlink dialog. Read as F5 reads where to copy to, each
/// symbolic link pointing at its source by the absolute path. An entry that
/// cannot be linked - a directory, for a hard link - is passed over and
/// named, and the rest are still made.
pub(super) fn handle_symlink_confirm(app_state: &mut AppState) {
    let Some(Dialog::Symlink { sources, input, opened_with, default, hard }) = app_state.dialog.take() else {
        return;
    };
    let links = if input.text == opened_with {
        match &sources[..] {
            [(source, is_dir)] => Ok(vec![(source.clone(), default, *is_dir)]),
            _ => transfer_targets(&sources, &default, true),
        }
    } else if input.text.trim().is_empty() {
        return;
    } else {
        let base = if app_state.is_left_active { &app_state.dir_left } else { &app_state.dir_right };
        transfer_targets(&sources, &typed_path(&input.text, base), input.text.ends_with(std::path::is_separator))
    };
    // Made inside an archive: the other panel in one, or a path typed
    // through one.
    if let Ok(links) = &links
        && links.iter().any(|(_, link, _)| link.parent().is_some_and(|dir| crate::archive::locate(dir).is_some()))
    {
        app_state.display_error(READ_ONLY.to_string());
        return;
    }
    let links = match links {
        Ok(links) => links,
        Err(message) => {
            app_state.display_error(message);
            return;
        }
    };

    let mut failed = Vec::new();
    for (target, link, is_dir) in &links {
        let made = if hard { create_hard_link(target, link, *is_dir) } else { create_symlink(target, link, *is_dir) };
        if let Err(e) = made {
            failed.push(format!("{}: {}", link.display(), e));
        }
    }

    // A single link is landed on, in whichever panel shows where it went.
    let landed = match &links[..] {
        [(_, link, _)] if failed.is_empty() => link.parent().zip(link.file_name()),
        _ => None,
    };
    for is_left in [true, false] {
        let name = landed.filter(|(parent, _)| app_state.panel_dir(is_left) == *parent).map(|(_, name)| name);
        app_state.reload_panel(is_left, name);
    }
    match failed.len() {
        0 => {}
        1 if links.len() == 1 => app_state.display_error(format!("Cannot link {}", failed[0])),
        count => app_state.display_error(format!("Could not link {count} of {}. {}", links.len(), failed[0])),
    }
}