Skip to content
Open
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
43 changes: 39 additions & 4 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,8 +83,33 @@ fn apply_effects(app: &tauri::App) -> &'static str {
"none"
}

/// Detects if portable mode is active (a `data` folder or `portable.dat` file exists next to the executable).
fn detect_portable_dir() -> Option<std::path::PathBuf> {
let exe = std::env::current_exe().ok()?;
let exe_dir = exe.parent()?;
let data_dir = exe_dir.join("data");
let marker = exe_dir.join("portable.dat");
if data_dir.is_dir() || marker.is_file() {
let _ = std::fs::create_dir_all(&data_dir);
Some(data_dir)
} else {
None
}
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let portable_dir = detect_portable_dir();
if let Some(ref p) = portable_dir {
// Redirect WebView2 data folder on Windows so no browser cache/state leaks to the host machine.
#[cfg(windows)]
{
let webview_dir = p.join("webview");
let _ = std::fs::create_dir_all(&webview_dir);
std::env::set_var("WEBVIEW2_USER_DATA_FOLDER", webview_dir);
}
}

let app = tauri::Builder::default()
.plugin(tauri_plugin_window_state::Builder::default().build())
.plugin(tauri_plugin_dialog::init())
Expand All @@ -109,16 +134,26 @@ pub fn run() {
respond(responder, png.map(|p| (p.to_vec(), if p.starts_with(b"\x89PNG") { "image/png" } else { "image/svg+xml" })));
});
})
.setup(|app| {
.setup(move |app| {
let handle = app.handle().clone();
let config_dir = app.path().app_config_dir()?;
let data_dir = app.path().app_data_dir()?;
let (config_dir, data_dir, cache_dir) = if let Some(ref p) = portable_dir {
let conf = p.join("config");
let data = p.join("data");
let cache = p.join("cache");
let _ = std::fs::create_dir_all(&conf);
let _ = std::fs::create_dir_all(&data);
let _ = std::fs::create_dir_all(&cache);
(conf, data, Some(cache))
} else {
(app.path().app_config_dir()?, app.path().app_data_dir()?, app.path().app_cache_dir().ok())
};

platform::set_data_dir(data_dir.clone());
let notify = handle.clone();
network::set_notify(move || {
let _ = notify.emit("network-changed", ());
});
if let Ok(cache) = app.path().app_cache_dir() {
if let Some(cache) = cache_dir {
thumbs::set_cache_dir(cache.join("thumbs"));
}
let remote = Arc::new(remote::Registry::default());
Expand Down
71 changes: 69 additions & 2 deletions src-tauri/src/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,71 @@ struct Compiled {
text: Option<RegexMatcher>,
}

/// Avoid contending on the shared result queue for every match. Branch view
/// can match nearly every file, unlike ordinary searches where hits are rare.
struct HitBatch {
pending: Arc<Mutex<Vec<Hit>>>,
items: Vec<Hit>,
}

impl HitBatch {
fn new(pending: Arc<Mutex<Vec<Hit>>>) -> Self {
Self { pending, items: Vec::with_capacity(128) }
}

fn push(&mut self, hit: Hit) {
self.items.push(hit);
if self.items.len() >= 128 {
self.flush();
}
}

fn flush(&mut self) {
if !self.items.is_empty() {
self.pending.lock().append(&mut self.items);
}
}
}

impl Drop for HitBatch {
fn drop(&mut self) {
self.flush();
}
}

/// Keep the progress counter mostly local to each walker thread as well.
struct ScanBatch {
total: Arc<AtomicU64>,
count: u64,
}

impl ScanBatch {
fn new(total: Arc<AtomicU64>) -> Self {
Self { total, count: 0 }
}

fn next(&mut self) -> u64 {
self.count += 1;
if self.count >= 256 {
self.flush();
}
self.total.load(Ordering::Relaxed) + self.count
}

fn flush(&mut self) {
if self.count > 0 {
self.total.fetch_add(self.count, Ordering::Relaxed);
self.count = 0;
}
}
}

impl Drop for ScanBatch {
fn drop(&mut self) {
self.flush();
}
}

fn compile(p: &SearchParams) -> AppResult<Compiled> {
let name = if p.name.trim().is_empty() {
NameMatch::Any
Expand Down Expand Up @@ -315,6 +380,8 @@ pub fn run(params: SearchParams, channel: Channel<SearchEvent>, cancel: Arc<Atom
cancel.clone(),
);
let mut grepper = Grepper::new(&params);
let mut hit_batch = HitBatch::new(pending.clone());
let mut scan_batch = ScanBatch::new(scanned.clone());
Box::new(move |res| {
if cancel.load(Ordering::Relaxed) {
return ignore::WalkState::Quit;
Expand All @@ -323,7 +390,7 @@ pub fn run(params: SearchParams, channel: Channel<SearchEvent>, cancel: Arc<Atom
if entry.depth() == 0 {
return ignore::WalkState::Continue;
}
let n = scanned.fetch_add(1, Ordering::Relaxed);
let n = scan_batch.next();
let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
if is_dir && n % 64 == 0 {
if let Some(mut c) = current.try_lock() {
Expand Down Expand Up @@ -385,7 +452,7 @@ pub fn run(params: SearchParams, channel: Channel<SearchEvent>, cancel: Arc<Atom
match_count: count,
cloud,
};
pending.lock().push(hit);
hit_batch.push(hit);
if found.fetch_add(1, Ordering::Relaxed) + 1 >= limit {
truncated.store(true, Ordering::Relaxed);
return ignore::WalkState::Quit;
Expand Down
125 changes: 125 additions & 0 deletions src/components/FavoritesBar.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
<script lang="ts">
import Folder from '@lucide/svelte/icons/folder';
import Plus from '@lucide/svelte/icons/plus';
import Search from '@lucide/svelte/icons/search';
import Star from '@lucide/svelte/icons/star';
import { run } from '../lib/commands';
import { drag } from '../lib/dnd.svelte';
import { t } from '../lib/i18n/index.svelte';
import { locKey, locPath, sameLoc } from '../lib/paths';
import { dpr, pathIconUrl } from '../lib/platform';
import { app } from '../lib/state/app.svelte';
import { settings, type HotlistItem } from '../lib/state/settings.svelte';

let pointer: { x: number; y: number; location: HotlistItem['location']; moved: boolean } | null = null;
let suppressClick = false;
const iconPx = Math.round(18 * dpr());

function open(item: HotlistItem) {
if (suppressClick) return;
void app.panel.navigate(item.location);
}

function moveFavorite(location: HotlistItem['location'], to: number) {
const list = [...settings.hotlist];
const from = list.findIndex((item) => sameLoc(item.location, location));
if (from < 0 || from === to) return;
const [item] = list.splice(from, 1);
list.splice(to, 0, item);
settings.hotlist = list;
}

function pointerDown(e: PointerEvent, item: HotlistItem) {
if (e.button !== 0) return;
pointer = { x: e.clientX, y: e.clientY, location: item.location, moved: false };
window.addEventListener('pointermove', pointerMove);
window.addEventListener('pointerup', pointerUp);
window.addEventListener('pointercancel', pointerUp);
}

function pointerMove(e: PointerEvent) {
if (!pointer) return;
if (!pointer.moved && Math.hypot(e.clientX - pointer.x, e.clientY - pointer.y) > 6) {
pointer.moved = true;
suppressClick = true;
}
if (!pointer.moved) return;
const el = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null;
const index = Number(el?.closest<HTMLElement>('[data-favorite-index]')?.dataset.favoriteIndex);
if (Number.isInteger(index)) moveFavorite(pointer.location, index);
}

function pointerUp() {
window.removeEventListener('pointermove', pointerMove);
window.removeEventListener('pointerup', pointerUp);
window.removeEventListener('pointercancel', pointerUp);
pointer = null;
setTimeout(() => (suppressClick = false), 0);
}
</script>

<nav class="favorites" data-favorites-drop aria-label={t('cmd.hotlist')} class:drop={drag.active}>
<button class="toggle icon-btn" class:on={settings.favoritesBarOpen} title={t('cmd.hotlist')} aria-label={t('cmd.hotlist')} aria-pressed={settings.favoritesBarOpen} onclick={() => (settings.favoritesBarOpen = !settings.favoritesBarOpen)}>
<Star size={17} strokeWidth={1.7} />
</button>
{#if settings.favoritesBarOpen}
<div class="items">
{#each settings.hotlist as item, i (locKey(item.location))}
<button
class="favorite icon-btn"
data-favorite-index={i}
title={`${item.name}\n${locPath(item.location)}`}
aria-label={item.name}
onclick={() => open(item)}
onpointerdown={(e) => pointerDown(e, item)}
>
{#if item.location.kind === 'query' || item.location.kind === 'search'}
<Search size={17} strokeWidth={1.7} />
{:else if item.location.kind === 'local'}
<img src={pathIconUrl(item.location.path, iconPx)} alt="" width="18" height="18" draggable="false" />
{:else}
<Folder size={17} strokeWidth={1.7} />
{/if}
</button>
{/each}
<button class="add icon-btn" title={t('cmd.addHotlist')} aria-label={t('cmd.addHotlist')} onclick={() => run('addHotlist')}>
<Plus size={17} strokeWidth={1.7} />
</button>
</div>
{/if}
</nav>

<style>
.favorites {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 3px;
overflow: hidden;
padding: 0 4px;
}
.items {
min-width: 0;
display: flex;
align-items: center;
gap: 3px;
overflow-x: auto;
overflow-y: hidden;
}
.favorite {
flex: none;
width: 28px;
height: 28px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0;
cursor: grab;
}
.favorite:active { cursor: grabbing; }
.favorite img { flex: none; object-fit: contain; }
.toggle, .add { flex: none; width: 28px; height: 28px; }
.favorites.drop { background: var(--bg-hover); border-radius: var(--r-s); }
</style>
8 changes: 5 additions & 3 deletions src/components/FileGrid.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import { drag } from '../lib/dnd.svelte';
import { setMetrics } from '../lib/panelKeys.svelte';
import { isWindows } from '../lib/platform';
import { backgroundPointerDown, itemPointerDown } from '../lib/pointer';
import { backgroundPointerDown, canOpenContextMenu, itemPointerDown } from '../lib/pointer';
import type { SideName } from '../lib/state/app.svelte';
import { settings } from '../lib/state/settings.svelte';
import type { Tab } from '../lib/state/tab.svelte';
Expand Down Expand Up @@ -105,8 +105,10 @@

function onContext(e: MouseEvent, entry: Entry | null) {
e.preventDefault();
if (entry && !(entry.flags & F.UP)) void entryMenu(tab);
else void backgroundMenu(tab);
if (canOpenContextMenu(e, entry)) {
if (entry && !(entry.flags & F.UP)) void entryMenu(tab);
else void backgroundMenu(tab);
}
}
</script>

Expand Down
8 changes: 5 additions & 3 deletions src/components/FileList.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import { localeTag, t } from '../lib/i18n/index.svelte';
import { setMetrics } from '../lib/panelKeys.svelte';
import { isWindows } from '../lib/platform';
import { backgroundPointerDown, itemPointerDown } from '../lib/pointer';
import { backgroundPointerDown, canOpenContextMenu, itemPointerDown } from '../lib/pointer';
import type { SideName } from '../lib/state/app.svelte';
import { settings } from '../lib/state/settings.svelte';
import type { Tab } from '../lib/state/tab.svelte';
Expand Down Expand Up @@ -135,8 +135,10 @@

function onContext(e: MouseEvent, entry: Entry | null) {
e.preventDefault();
if (entry && !(entry.flags & F.UP)) void entryMenu(tab);
else void backgroundMenu(tab);
if (canOpenContextMenu(e, entry)) {
if (entry && !(entry.flags & F.UP)) void entryMenu(tab);
else void backgroundMenu(tab);
}
}
</script>

Expand Down
16 changes: 12 additions & 4 deletions src/components/Toolbar.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import Server from '@lucide/svelte/icons/server';
import Settings from '@lucide/svelte/icons/settings';
import SquareTerminal from '@lucide/svelte/icons/square-terminal';
import FavoritesBar from './FavoritesBar.svelte';
import { run, shortcutOf } from '../lib/commands';
import { t } from '../lib/i18n/index.svelte';
import { isMac, shortcutLabel } from '../lib/platform';
Expand Down Expand Up @@ -108,7 +109,8 @@
<Server {size} strokeWidth={sw} />
</button>
</div>

<span class="divider" aria-hidden="true"></span>
<FavoritesBar />
</div>

<div class="trail" data-tauri-drag-region>
Expand All @@ -134,6 +136,7 @@
align-items: center;
gap: 10px;
min-width: 0;
flex: 1;
}
.lead {
display: none;
Expand All @@ -147,24 +150,29 @@
}
/* macOS: actions centred in the unified title bar, like Finder/Safari. */
.toolbar.mac {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
display: flex;
padding: 2px 10px 0 0;
}
.toolbar.mac .lead {
display: block;
align-self: stretch;
min-width: var(--chrome-left);
flex: none;
}
.toolbar.mac .trail {
justify-self: end;
margin-left: 0;
}
.group {
display: flex;
align-items: center;
gap: 2px;
}
.divider {
width: 1px;
height: 18px;
flex: none;
background: var(--border-strong);
}
.segmented {
display: flex;
align-items: center;
Expand Down
Loading