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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ All notable changes to this project will be documented in this file.
- The Site Manager has its Browse button back for choosing a private key file, and it opens where the current path points rather than at your home folder.

### Fixed
- Backspace, Alt+Left, and Alt+Up in a file pane now go to the parent directory. Those keys were bound to a list event that never reported which key was pressed, so they did nothing in either pane. Ctrl+Up and Ctrl+[ do the same (Command+Up and Command+[ on a Mac, matching Finder).
- The exit sound was cut off as the program closed. Closing now waits for it to finish.
- Uploading a file always asked whether to replace it, even when nothing of that name was on the server. The prompt now appears only when the remote folder already has that name, matching downloads.
- Installing an update no longer asks you to close Portkey Drop first, and no longer leaves the app sitting there with nothing happening. Setup checks whether the app is running, and it was being started while the app was still on screen. Portkey Drop now closes itself and Setup opens on its own first page, the way the portable and macOS updates already worked. Quitting for an update is also final now: the download's progress window could keep the program alive after its window had closed, which left the update waiting for a program that never went away.
- Leaving Portkey Drop while a transfer was running froze the window instead of closing it. Quitting closed the connection, and that waited for the transfer to let go of it -- on the window's own thread, so nothing repainted until the transfer finished. The connection is now closed out of the way of the window.
- Downloading a folder no longer freezes the window for the length of the transfer. The connection check behind the status bar and tray tooltip waited for the connection to be free, and a transfer holds it for as long as its work takes -- the whole listing of a folder, then each file in turn -- so the window stopped repainting and Windows offered to close it. The check no longer waits: a connection busy with a transfer is reported as connected.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Portkey Drop includes a built-in default sound pack with short cues for transfer
| Ctrl+Shift+T | Transfer queue |
| Ctrl+Enter | Connect using the quick connect bar |
| Enter | Open directory / download file |
| Backspace | Parent directory |
| Backspace, Alt+Left, Alt+Up, Ctrl+Up, Ctrl+[ | Parent directory |
| Delete | Delete selected |
| F2 | Rename selected |

Expand Down
56 changes: 43 additions & 13 deletions crates/portkeydrop-core/src/local_files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -165,26 +165,45 @@ pub fn mkdir_local(parent: &Path, name: &str) -> std::io::Result<PathBuf> {
/// Appends ` (1)`, ` (2)`, ... before the extension, matching what browsers and
/// file managers do.
pub fn unique_local_path(path: &Path) -> PathBuf {
if !path.exists() {
return path.to_path_buf();
}
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let stem = path
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
let extension = path
.extension()
.map(|s| format!(".{}", s.to_string_lossy()))
let name = path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default();
parent.join(unique_file_name(&name, |candidate| {
parent.join(candidate).exists()
}))
}

/// A file name that does not collide with names `exists` reports as taken.
///
/// Same numbering as [`unique_local_path`], so upload-and-keep-both and
/// download-and-keep-both produce the same kind of name.
pub fn unique_file_name(name: &str, exists: impl Fn(&str) -> bool) -> String {
if !exists(name) {
return name.to_string();
}
let (stem, extension) = split_file_name(name);
for counter in 1..10_000 {
let candidate = parent.join(format!("{stem} ({counter}){extension}"));
if !candidate.exists() {
let candidate = format!("{stem} ({counter}){extension}");
if !exists(&candidate) {
return candidate;
}
}
path.to_path_buf()
name.to_string()
}

/// Split `name` into stem and extension, including the dot on the extension.
///
/// A leading dot is part of the stem (`.hidden` stays `.hidden (1)`, not
/// ` (1).hidden`). `Path::file_stem` on Windows treats that as an extension.
fn split_file_name(name: &str) -> (&str, String) {
match name.rsplit_once('.') {
Some((stem, ext)) if !stem.is_empty() && !ext.is_empty() && !ext.contains('/') => {
(stem, format!(".{ext}"))
}
_ => (name, String::new()),
}
}

#[cfg(test)]
Expand Down Expand Up @@ -320,4 +339,15 @@ mod tests {
std::fs::write(&path, b"x").unwrap();
assert_eq!(unique_local_path(&path), dir.path().join("README (1)"));
}

#[test]
fn a_leading_dot_stays_on_the_stem() {
let taken = |name: &str| name == ".hidden";
assert_eq!(unique_file_name(".hidden", taken), ".hidden (1)");
assert_eq!(unique_file_name("notes.txt", |_| false), "notes.txt");
assert_eq!(
unique_file_name("notes.txt", |name| name == "notes.txt"),
"notes (1).txt"
);
}
}
2 changes: 1 addition & 1 deletion crates/portkeydrop-core/src/soundpacks/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ use std::path::{Path, PathBuf};

pub use install::{is_safe_archive_name, InstallError, PackInstaller};
pub use manifest::{PackManifest, SoundEntry};
pub use player::{can_decode, play_sound_file, SoundPlayer};
pub use player::{can_decode, play_sound_file, wait_for_playback, SoundPlayer, EXIT_SOUND_TIMEOUT};

/// Directory name of the built-in pack.
pub const DEFAULT_PACK: &str = "default";
Expand Down
48 changes: 47 additions & 1 deletion crates/portkeydrop-core/src/soundpacks/player.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,21 @@
//! Sounds are feedback, never a gate on anything: if the audio device is
//! missing, busy, or the file will not decode, playback reports `false` and the
//! app carries on. Nothing here blocks the caller — a transfer must not wait on
//! a chime.
//! a chime. The exception is [`wait_for_playback`], used on exit so the closing
//! sound is not cut off when the process dies.

use std::path::{Path, PathBuf};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};

use rodio::{Decoder, OutputStream, OutputStreamHandle, Sink};

/// Longest closing will wait for the exit sound before giving up.
///
/// Playback is otherwise fire-and-forget; this cap is so a hung audio
/// device cannot trap the process.
pub const EXIT_SOUND_TIMEOUT: Duration = Duration::from_secs(8);

use super::{resolve_sound, DEFAULT_PACK};

/// The process-wide audio output.
Expand Down Expand Up @@ -113,6 +121,27 @@ pub fn play_sound_file(path: &Path, volume: f32) -> bool {
true
}

/// Wait until every started sound has finished, or `timeout` elapses.
///
/// Used on exit so the closing chime is not cut off when the process dies.
/// Other playback stays fire-and-forget: a transfer must not wait on a chime.
pub fn wait_for_playback(timeout: Duration) {
let started = Instant::now();
while started.elapsed() < timeout {
let playing = match active_sinks().lock() {
Ok(mut sinks) => {
sinks.retain(|sink| !sink.empty());
!sinks.is_empty()
}
Err(_) => return,
};
if !playing {
return;
}
std::thread::sleep(Duration::from_millis(20));
}
}

/// Plays event sounds from the active pack.
#[derive(Debug, Clone)]
pub struct SoundPlayer {
Expand Down Expand Up @@ -291,4 +320,21 @@ mod tests {
std::fs::write(&path, b"definitely not audio").unwrap();
assert!(!play_sound_file(&path, 1.0));
}

#[test]
fn waiting_with_nothing_playing_returns_immediately() {
let start = Instant::now();
wait_for_playback(Duration::from_secs(5));
assert!(
start.elapsed() < Duration::from_millis(500),
"idle wait took {:?}",
start.elapsed()
);
}

#[test]
fn the_exit_wait_is_long_enough_for_a_chime_and_short_enough_not_to_trap() {
assert!(EXIT_SOUND_TIMEOUT >= Duration::from_secs(2));
assert!(EXIT_SOUND_TIMEOUT <= Duration::from_secs(10));
}
}
6 changes: 6 additions & 0 deletions crates/portkeydrop/src/ui/file_pane.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,12 @@ impl FilePane {
self.state.borrow().path.clone()
}

/// Whether the listing includes an entry with this name, including hidden
/// files the list is not showing.
pub fn contains_name(&self, name: &str) -> bool {
self.state.borrow().contains_name(name)
}

/// The path as typed into the path bar.
pub fn typed_path(&self) -> String {
self.path_bar.get_value().trim().to_string()
Expand Down
36 changes: 35 additions & 1 deletion crates/portkeydrop/src/ui/ids.rs
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,30 @@ pub const SHORTCUTS: &[Shortcut] = &[
description: "Go to the parent directory",
binding: Binding::Control,
},
Shortcut {
section: "Navigation",
keys: "Alt+Left",
description: "Go to the parent directory",
binding: Binding::Control,
},
Shortcut {
section: "Navigation",
keys: "Alt+Up",
description: "Go to the parent directory",
binding: Binding::Control,
},
Shortcut {
section: "Navigation",
keys: "Ctrl+Up",
description: "Go to the parent directory",
binding: Binding::Control,
},
Shortcut {
section: "Navigation",
keys: "Ctrl+[",
description: "Go to the parent directory",
binding: Binding::Control,
},
Shortcut {
section: "Navigation",
keys: "Ctrl+H",
Expand Down Expand Up @@ -416,7 +440,17 @@ mod tests {
fn keys_that_must_not_be_frame_wide_are_control_bound() {
// Delete and F2 as menubar accelerators would fire inside every text
// field in the window, so they belong to the file lists.
for keys in ["Delete", "F2", "Backspace", "Escape", "Shift+F10"] {
for keys in [
"Delete",
"F2",
"Backspace",
"Alt+Left",
"Alt+Up",
"Ctrl+Up",
"Ctrl+[",
"Escape",
"Shift+F10",
] {
let shortcut = SHORTCUTS
.iter()
.find(|shortcut| shortcut.keys == keys)
Expand Down
Loading