Skip to content

Commit 74cb8c4

Browse files
committed
Merge main into spacedrive-data
2 parents 6c5e656 + be454a0 commit 74cb8c4

16 files changed

Lines changed: 392 additions & 97 deletions

File tree

apps/tauri/src-tauri/src/main.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2004,6 +2004,14 @@ fn main() {
20042004

20052005
tracing::info!("Spacedrive Tauri app starting...");
20062006

2007+
// Apply Windows-specific window customizations (dark titlebar)
2008+
#[cfg(target_os = "windows")]
2009+
{
2010+
if let Some(window) = app.get_webview_window("main") {
2011+
crate::windows::apply_dark_titlebar_pub(&window);
2012+
}
2013+
}
2014+
20072015
// Apply macOS-specific window customizations
20082016
#[cfg(target_os = "macos")]
20092017
{

apps/tauri/src-tauri/src/windows.rs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -390,6 +390,9 @@ impl SpacedriveWindow {
390390
.build()
391391
.map_err(|e| format!("Failed to create context menu: {}", e))?;
392392

393+
#[cfg(target_os = "windows")]
394+
apply_dark_titlebar(&window);
395+
393396
Ok(window)
394397
}
395398
}
@@ -435,6 +438,10 @@ fn create_window(
435438
.build()
436439
.map_err(|e| format!("Failed to create window: {}", e))?;
437440

441+
// Windows: force dark titlebar + override accent color
442+
#[cfg(target_os = "windows")]
443+
apply_dark_titlebar(&window);
444+
438445
window.show().ok();
439446
window.set_focus().ok();
440447

@@ -616,3 +623,76 @@ pub async fn position_context_menu(
616623

617624
Ok(())
618625
}
626+
627+
/// Apply dark titlebar on Windows using DWM API.
628+
///
629+
/// Sets both `DWMWA_USE_IMMERSIVE_DARK_MODE` (dark window chrome) and
630+
/// `DWMWA_CAPTION_COLOR` (explicit titlebar color) to override the user's
631+
/// Windows accent color setting which would otherwise tint the titlebar.
632+
#[cfg(target_os = "windows")]
633+
pub fn apply_dark_titlebar_pub(window: &WebviewWindow) {
634+
apply_dark_titlebar(window);
635+
}
636+
637+
#[cfg(target_os = "windows")]
638+
fn apply_dark_titlebar(window: &WebviewWindow) {
639+
#[allow(non_snake_case)]
640+
mod dwm {
641+
// DWM attribute constants
642+
pub const DWMWA_USE_IMMERSIVE_DARK_MODE: u32 = 20;
643+
pub const DWMWA_CAPTION_COLOR: u32 = 35;
644+
pub const DWMWA_BORDER_COLOR: u32 = 34;
645+
646+
extern "system" {
647+
pub fn DwmSetWindowAttribute(
648+
hwnd: isize,
649+
attr: u32,
650+
value: *const std::ffi::c_void,
651+
size: u32,
652+
) -> i32;
653+
}
654+
}
655+
656+
let Ok(hwnd) = window.hwnd() else {
657+
tracing::warn!("Failed to get HWND for dark titlebar");
658+
return;
659+
};
660+
let hwnd = hwnd.0 as isize;
661+
662+
unsafe {
663+
let set_attr =
664+
|attr: u32, value: *const std::ffi::c_void, size: u32, name: &'static str| {
665+
let hr = dwm::DwmSetWindowAttribute(hwnd, attr, value, size);
666+
if hr < 0 {
667+
tracing::warn!(attribute = name, hr, "Failed to apply DWM window attribute");
668+
}
669+
};
670+
671+
// Enable immersive dark mode (dark close/minimize/maximize icons)
672+
let dark_mode: i32 = 1;
673+
set_attr(
674+
dwm::DWMWA_USE_IMMERSIVE_DARK_MODE,
675+
&dark_mode as *const _ as *const std::ffi::c_void,
676+
std::mem::size_of::<i32>() as u32,
677+
"DWMWA_USE_IMMERSIVE_DARK_MODE",
678+
);
679+
680+
// Force caption color to dark gray — overrides user's accent color
681+
// COLORREF format is 0x00BBGGRR
682+
let caption_color: u32 = 0x00_1E_1E_1E; // #1E1E1E in BGR
683+
set_attr(
684+
dwm::DWMWA_CAPTION_COLOR,
685+
&caption_color as *const _ as *const std::ffi::c_void,
686+
std::mem::size_of::<u32>() as u32,
687+
"DWMWA_CAPTION_COLOR",
688+
);
689+
690+
// Match border color to caption
691+
set_attr(
692+
dwm::DWMWA_BORDER_COLOR,
693+
&caption_color as *const _ as *const std::ffi::c_void,
694+
std::mem::size_of::<u32>() as u32,
695+
"DWMWA_BORDER_COLOR",
696+
);
697+
}
698+
}

core/src/common/utils.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,41 @@
33
// Note: Device ID management has been moved to device::manager for better
44
// module organization. Import from there instead:
55
// use crate::device::manager::{get_current_device_id, set_current_device_id};
6+
7+
/// Strip Windows extended path prefixes produced by `std::fs::canonicalize()`.
8+
///
9+
/// On Windows, `canonicalize()` returns paths like `\\?\C:\...` (local) or
10+
/// `\\?\UNC\server\share\...` (network). These prefixes break `starts_with()`
11+
/// matching throughout the codebase and must be normalized.
12+
///
13+
/// - `\\?\UNC\server\share\...` → `\\server\share\...`
14+
/// - `\\?\C:\...` → `C:\...`
15+
/// - All other paths are returned unchanged.
16+
#[cfg(windows)]
17+
pub fn strip_windows_extended_prefix(path: std::path::PathBuf) -> std::path::PathBuf {
18+
if let Some(s) = path.to_str() {
19+
if s.starts_with(r"\\?\UNC\") {
20+
// \\?\UNC\server\share\... → \\server\share\...
21+
std::path::PathBuf::from(format!(r"\\{}", &s[8..]))
22+
} else if let Some(stripped) = s.strip_prefix(r"\\?\") {
23+
// Only strip \\?\ when followed by a drive letter (e.g. C:\).
24+
// Leave volume GUIDs (\\?\Volume{...}\) and other verbatim
25+
// forms untouched — they are invalid without the prefix.
26+
if stripped.as_bytes().get(1) == Some(&b':') {
27+
std::path::PathBuf::from(stripped)
28+
} else {
29+
path
30+
}
31+
} else {
32+
path
33+
}
34+
} else {
35+
path
36+
}
37+
}
38+
39+
/// No-op on non-Windows platforms.
40+
#[cfg(not(windows))]
41+
pub fn strip_windows_extended_prefix(path: std::path::PathBuf) -> std::path::PathBuf {
42+
path
43+
}

core/src/domain/addressing.rs

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -682,7 +682,82 @@ impl SdPath {
682682
Self::Physical { .. } => Ok(self.clone()),
683683
Self::Cloud { .. } => Ok(self.clone()), // Cloud paths are already resolved
684684
Self::Content { content_id } => {
685-
// In the future, use job_ctx.library_db() to query for content instances
685+
use sea_orm::{ColumnTrait, EntityTrait, ModelTrait, QueryFilter};
686+
use crate::infra::db::entities::{
687+
content_identity, device, location, ContentIdentity, Device, DirectoryPaths,
688+
Entry, Location,
689+
};
690+
691+
let db = job_ctx.library_db();
692+
let current_device_id = get_current_device_id();
693+
let current_device_slug = get_current_device_slug();
694+
695+
let ci = ContentIdentity::find()
696+
.filter(content_identity::Column::Uuid.eq(Some(*content_id)))
697+
.one(db)
698+
.await
699+
.map_err(|e| PathResolutionError::DatabaseError(e.to_string()))?
700+
.ok_or(PathResolutionError::NoOnlineInstancesFound(*content_id))?;
701+
702+
let entries = Entry::find()
703+
.filter(
704+
crate::infra::db::entities::entry::Column::ContentId
705+
.eq(Some(ci.id)),
706+
)
707+
.all(db)
708+
.await
709+
.map_err(|e| PathResolutionError::DatabaseError(e.to_string()))?;
710+
711+
for entry in entries {
712+
let loc = Location::find()
713+
.filter(location::Column::EntryId.eq(entry.id))
714+
.one(db)
715+
.await
716+
.map_err(|e| PathResolutionError::DatabaseError(e.to_string()))?;
717+
718+
if let Some(loc) = loc {
719+
let dev = Device::find_by_id(loc.device_id)
720+
.one(db)
721+
.await
722+
.map_err(|e| PathResolutionError::DatabaseError(e.to_string()))?;
723+
724+
if dev.map(|d| d.uuid) == Some(current_device_id) {
725+
// Build path from directory_paths cache
726+
let path = if let Some(parent_id) = entry.parent_id {
727+
let parent = DirectoryPaths::find_by_id(parent_id)
728+
.one(db)
729+
.await
730+
.map_err(|e| {
731+
PathResolutionError::DatabaseError(e.to_string())
732+
})?
733+
.ok_or_else(|| {
734+
PathResolutionError::DatabaseError(format!(
735+
"Parent path not found for entry {}",
736+
entry.id
737+
))
738+
})?;
739+
let filename = match &entry.extension {
740+
Some(ext) => format!("{}.{}", entry.name, ext),
741+
None => entry.name.clone(),
742+
};
743+
std::path::PathBuf::from(parent.path).join(filename)
744+
} else {
745+
return Err(PathResolutionError::DatabaseError(
746+
format!(
747+
"Entry {} has no parent_id, cannot build absolute path",
748+
entry.id
749+
),
750+
));
751+
};
752+
753+
return Ok(SdPath::Physical {
754+
device_slug: current_device_slug,
755+
path,
756+
});
757+
}
758+
}
759+
}
760+
686761
Err(PathResolutionError::NoOnlineInstancesFound(*content_id))
687762
}
688763
Self::Sidecar { content_id, .. } => {

core/src/location/manager.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,30 @@ impl LocationManager {
4646
job_policies: Option<String>,
4747
volume_manager: &crate::volume::VolumeManager,
4848
) -> LocationResult<(Uuid, String)> {
49+
// Canonicalize local physical paths to absolute form before storing.
50+
// Relative paths break the watcher, volume resolution, and indexer.
51+
// Only for local device — remote paths can't be resolved locally.
52+
let sd_path = if sd_path.is_local() {
53+
if let crate::domain::addressing::SdPath::Physical { device_slug, path } = sd_path {
54+
let canonical = tokio::fs::canonicalize(&path).await.map_err(|e| {
55+
LocationError::InvalidPath(format!(
56+
"Failed to resolve path {}: {}",
57+
path.display(),
58+
e
59+
))
60+
})?;
61+
let canonical = crate::common::utils::strip_windows_extended_prefix(canonical);
62+
crate::domain::addressing::SdPath::Physical {
63+
device_slug,
64+
path: canonical,
65+
}
66+
} else {
67+
sd_path
68+
}
69+
} else {
70+
sd_path
71+
};
72+
4973
info!("Adding location: {}", sd_path);
5074

5175
// Validate the path based on type

core/src/ops/addressing.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@ impl PathResolver {
3737
// Cloud paths are already resolved (no additional resolution needed)
3838
SdPath::Cloud { .. } => Ok(path.clone()),
3939
// If content-based, find the optimal physical path
40-
SdPath::Content { content_id } => unimplemented!(),
40+
SdPath::Content { content_id } => {
41+
Err(PathResolutionError::NoOnlineInstancesFound(*content_id))
42+
}
4143
// Sidecar paths need to be resolved to physical locations
4244
SdPath::Sidecar {
4345
content_id,

core/src/ops/files/delete/job.rs

Lines changed: 49 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
//! Delete job implementation
22
3-
use crate::{domain::addressing::SdPathBatch, infra::job::prelude::*};
3+
use crate::{
4+
domain::addressing::SdPathBatch,
5+
infra::job::{generic_progress::GenericProgress, prelude::*},
6+
};
47
use serde::{Deserialize, Serialize};
58
use std::{
69
path::PathBuf,
@@ -51,19 +54,6 @@ pub struct DeleteJob {
5154
started_at: Instant,
5255
}
5356

54-
/// Delete progress information
55-
#[derive(Debug, Clone, Serialize, Deserialize)]
56-
pub struct DeleteProgress {
57-
pub current_file: String,
58-
pub files_deleted: usize,
59-
pub total_files: usize,
60-
pub bytes_deleted: u64,
61-
pub total_bytes: u64,
62-
pub current_operation: String,
63-
pub estimated_remaining: Option<Duration>,
64-
}
65-
66-
impl JobProgress for DeleteProgress {}
6757

6858
impl Job for DeleteJob {
6959
const NAME: &'static str = "delete_files";
@@ -84,14 +74,21 @@ impl JobHandler for DeleteJob {
8474
type Output = DeleteOutput;
8575

8676
async fn run(&mut self, ctx: JobContext<'_>) -> JobResult<Self::Output> {
77+
let total_files = self.targets.paths.len();
78+
let mode_str = match self.mode {
79+
DeleteMode::Trash => "trash",
80+
DeleteMode::Permanent => "permanent",
81+
DeleteMode::Secure => "secure",
82+
};
83+
8784
ctx.log(format!(
8885
"Starting {} deletion of {} files",
89-
match self.mode {
90-
DeleteMode::Trash => "trash",
91-
DeleteMode::Permanent => "permanent",
92-
DeleteMode::Secure => "secure",
93-
},
94-
self.targets.paths.len()
86+
mode_str, total_files
87+
));
88+
89+
// Phase: Preparing
90+
ctx.progress(Progress::Indeterminate(
91+
format!("Validating {} targets", total_files),
9592
));
9693

9794
// Safety check for permanent deletion
@@ -106,6 +103,20 @@ impl JobHandler for DeleteJob {
106103
// Validate targets exist (only for local paths)
107104
self.validate_targets(&ctx).await?;
108105

106+
// Phase: Resolving paths
107+
ctx.progress(Progress::Indeterminate("Resolving paths".to_string()));
108+
109+
// Resolve Content paths to Physical paths before strategy selection
110+
let mut resolved = Vec::with_capacity(self.targets.paths.len());
111+
for path in &self.targets.paths {
112+
resolved.push(
113+
path.resolve_in_job(&ctx)
114+
.await
115+
.map_err(|e| JobError::execution(format!("Failed to resolve path: {e}")))?,
116+
);
117+
}
118+
self.targets = SdPathBatch::new(resolved);
119+
109120
// Select strategy based on path topology
110121
let volume_manager = ctx.volume_manager();
111122
let strategy =
@@ -116,6 +127,11 @@ impl JobHandler for DeleteJob {
116127
DeleteStrategyRouter::describe_strategy(&self.targets.paths).await;
117128
ctx.log(format!("Using strategy: {}", strategy_description));
118129

130+
// Phase: Deleting
131+
ctx.progress(Progress::Indeterminate(
132+
format!("Deleting {} files ({})", total_files, mode_str),
133+
));
134+
119135
// Execute deletion using selected strategy
120136
let results = strategy
121137
.execute(&ctx, &self.targets.paths, self.mode.clone())
@@ -140,6 +156,19 @@ impl JobHandler for DeleteJob {
140156
})
141157
.collect();
142158

159+
// Phase: Complete
160+
ctx.progress(Progress::Generic(
161+
GenericProgress::new(
162+
1.0,
163+
"Complete",
164+
format!("{} deleted, {} failed", deleted_count, failed_count),
165+
)
166+
.with_completion(total_files as u64, total_files as u64)
167+
.with_bytes(total_bytes, total_bytes)
168+
.with_performance(0.0, None, Some(self.started_at.elapsed()))
169+
.with_errors(failed_count as u64, 0),
170+
));
171+
143172
ctx.log(format!(
144173
"Delete operation completed: {} deleted, {} failed",
145174
deleted_count, failed_count

0 commit comments

Comments
 (0)