Merge main into spacedrive-data

This commit is contained in:
Jamie Pine
2026-04-12 13:40:59 -07:00
16 changed files with 392 additions and 97 deletions

View File

@@ -2004,6 +2004,14 @@ fn main() {
tracing::info!("Spacedrive Tauri app starting...");
// Apply Windows-specific window customizations (dark titlebar)
#[cfg(target_os = "windows")]
{
if let Some(window) = app.get_webview_window("main") {
crate::windows::apply_dark_titlebar_pub(&window);
}
}
// Apply macOS-specific window customizations
#[cfg(target_os = "macos")]
{

View File

@@ -390,6 +390,9 @@ impl SpacedriveWindow {
.build()
.map_err(|e| format!("Failed to create context menu: {}", e))?;
#[cfg(target_os = "windows")]
apply_dark_titlebar(&window);
Ok(window)
}
}
@@ -435,6 +438,10 @@ fn create_window(
.build()
.map_err(|e| format!("Failed to create window: {}", e))?;
// Windows: force dark titlebar + override accent color
#[cfg(target_os = "windows")]
apply_dark_titlebar(&window);
window.show().ok();
window.set_focus().ok();
@@ -616,3 +623,76 @@ pub async fn position_context_menu(
Ok(())
}
/// Apply dark titlebar on Windows using DWM API.
///
/// Sets both `DWMWA_USE_IMMERSIVE_DARK_MODE` (dark window chrome) and
/// `DWMWA_CAPTION_COLOR` (explicit titlebar color) to override the user's
/// Windows accent color setting which would otherwise tint the titlebar.
#[cfg(target_os = "windows")]
pub fn apply_dark_titlebar_pub(window: &WebviewWindow) {
apply_dark_titlebar(window);
}
#[cfg(target_os = "windows")]
fn apply_dark_titlebar(window: &WebviewWindow) {
#[allow(non_snake_case)]
mod dwm {
// DWM attribute constants
pub const DWMWA_USE_IMMERSIVE_DARK_MODE: u32 = 20;
pub const DWMWA_CAPTION_COLOR: u32 = 35;
pub const DWMWA_BORDER_COLOR: u32 = 34;
extern "system" {
pub fn DwmSetWindowAttribute(
hwnd: isize,
attr: u32,
value: *const std::ffi::c_void,
size: u32,
) -> i32;
}
}
let Ok(hwnd) = window.hwnd() else {
tracing::warn!("Failed to get HWND for dark titlebar");
return;
};
let hwnd = hwnd.0 as isize;
unsafe {
let set_attr =
|attr: u32, value: *const std::ffi::c_void, size: u32, name: &'static str| {
let hr = dwm::DwmSetWindowAttribute(hwnd, attr, value, size);
if hr < 0 {
tracing::warn!(attribute = name, hr, "Failed to apply DWM window attribute");
}
};
// Enable immersive dark mode (dark close/minimize/maximize icons)
let dark_mode: i32 = 1;
set_attr(
dwm::DWMWA_USE_IMMERSIVE_DARK_MODE,
&dark_mode as *const _ as *const std::ffi::c_void,
std::mem::size_of::<i32>() as u32,
"DWMWA_USE_IMMERSIVE_DARK_MODE",
);
// Force caption color to dark gray — overrides user's accent color
// COLORREF format is 0x00BBGGRR
let caption_color: u32 = 0x00_1E_1E_1E; // #1E1E1E in BGR
set_attr(
dwm::DWMWA_CAPTION_COLOR,
&caption_color as *const _ as *const std::ffi::c_void,
std::mem::size_of::<u32>() as u32,
"DWMWA_CAPTION_COLOR",
);
// Match border color to caption
set_attr(
dwm::DWMWA_BORDER_COLOR,
&caption_color as *const _ as *const std::ffi::c_void,
std::mem::size_of::<u32>() as u32,
"DWMWA_BORDER_COLOR",
);
}
}

View File

@@ -3,3 +3,41 @@
// Note: Device ID management has been moved to device::manager for better
// module organization. Import from there instead:
// use crate::device::manager::{get_current_device_id, set_current_device_id};
/// Strip Windows extended path prefixes produced by `std::fs::canonicalize()`.
///
/// On Windows, `canonicalize()` returns paths like `\\?\C:\...` (local) or
/// `\\?\UNC\server\share\...` (network). These prefixes break `starts_with()`
/// matching throughout the codebase and must be normalized.
///
/// - `\\?\UNC\server\share\...` → `\\server\share\...`
/// - `\\?\C:\...` → `C:\...`
/// - All other paths are returned unchanged.
#[cfg(windows)]
pub fn strip_windows_extended_prefix(path: std::path::PathBuf) -> std::path::PathBuf {
if let Some(s) = path.to_str() {
if s.starts_with(r"\\?\UNC\") {
// \\?\UNC\server\share\... → \\server\share\...
std::path::PathBuf::from(format!(r"\\{}", &s[8..]))
} else if let Some(stripped) = s.strip_prefix(r"\\?\") {
// Only strip \\?\ when followed by a drive letter (e.g. C:\).
// Leave volume GUIDs (\\?\Volume{...}\) and other verbatim
// forms untouched — they are invalid without the prefix.
if stripped.as_bytes().get(1) == Some(&b':') {
std::path::PathBuf::from(stripped)
} else {
path
}
} else {
path
}
} else {
path
}
}
/// No-op on non-Windows platforms.
#[cfg(not(windows))]
pub fn strip_windows_extended_prefix(path: std::path::PathBuf) -> std::path::PathBuf {
path
}

View File

@@ -682,7 +682,82 @@ impl SdPath {
Self::Physical { .. } => Ok(self.clone()),
Self::Cloud { .. } => Ok(self.clone()), // Cloud paths are already resolved
Self::Content { content_id } => {
// In the future, use job_ctx.library_db() to query for content instances
use sea_orm::{ColumnTrait, EntityTrait, ModelTrait, QueryFilter};
use crate::infra::db::entities::{
content_identity, device, location, ContentIdentity, Device, DirectoryPaths,
Entry, Location,
};
let db = job_ctx.library_db();
let current_device_id = get_current_device_id();
let current_device_slug = get_current_device_slug();
let ci = ContentIdentity::find()
.filter(content_identity::Column::Uuid.eq(Some(*content_id)))
.one(db)
.await
.map_err(|e| PathResolutionError::DatabaseError(e.to_string()))?
.ok_or(PathResolutionError::NoOnlineInstancesFound(*content_id))?;
let entries = Entry::find()
.filter(
crate::infra::db::entities::entry::Column::ContentId
.eq(Some(ci.id)),
)
.all(db)
.await
.map_err(|e| PathResolutionError::DatabaseError(e.to_string()))?;
for entry in entries {
let loc = Location::find()
.filter(location::Column::EntryId.eq(entry.id))
.one(db)
.await
.map_err(|e| PathResolutionError::DatabaseError(e.to_string()))?;
if let Some(loc) = loc {
let dev = Device::find_by_id(loc.device_id)
.one(db)
.await
.map_err(|e| PathResolutionError::DatabaseError(e.to_string()))?;
if dev.map(|d| d.uuid) == Some(current_device_id) {
// Build path from directory_paths cache
let path = if let Some(parent_id) = entry.parent_id {
let parent = DirectoryPaths::find_by_id(parent_id)
.one(db)
.await
.map_err(|e| {
PathResolutionError::DatabaseError(e.to_string())
})?
.ok_or_else(|| {
PathResolutionError::DatabaseError(format!(
"Parent path not found for entry {}",
entry.id
))
})?;
let filename = match &entry.extension {
Some(ext) => format!("{}.{}", entry.name, ext),
None => entry.name.clone(),
};
std::path::PathBuf::from(parent.path).join(filename)
} else {
return Err(PathResolutionError::DatabaseError(
format!(
"Entry {} has no parent_id, cannot build absolute path",
entry.id
),
));
};
return Ok(SdPath::Physical {
device_slug: current_device_slug,
path,
});
}
}
}
Err(PathResolutionError::NoOnlineInstancesFound(*content_id))
}
Self::Sidecar { content_id, .. } => {

View File

@@ -46,6 +46,30 @@ impl LocationManager {
job_policies: Option<String>,
volume_manager: &crate::volume::VolumeManager,
) -> LocationResult<(Uuid, String)> {
// Canonicalize local physical paths to absolute form before storing.
// Relative paths break the watcher, volume resolution, and indexer.
// Only for local device — remote paths can't be resolved locally.
let sd_path = if sd_path.is_local() {
if let crate::domain::addressing::SdPath::Physical { device_slug, path } = sd_path {
let canonical = tokio::fs::canonicalize(&path).await.map_err(|e| {
LocationError::InvalidPath(format!(
"Failed to resolve path {}: {}",
path.display(),
e
))
})?;
let canonical = crate::common::utils::strip_windows_extended_prefix(canonical);
crate::domain::addressing::SdPath::Physical {
device_slug,
path: canonical,
}
} else {
sd_path
}
} else {
sd_path
};
info!("Adding location: {}", sd_path);
// Validate the path based on type

View File

@@ -37,7 +37,9 @@ impl PathResolver {
// Cloud paths are already resolved (no additional resolution needed)
SdPath::Cloud { .. } => Ok(path.clone()),
// If content-based, find the optimal physical path
SdPath::Content { content_id } => unimplemented!(),
SdPath::Content { content_id } => {
Err(PathResolutionError::NoOnlineInstancesFound(*content_id))
}
// Sidecar paths need to be resolved to physical locations
SdPath::Sidecar {
content_id,

View File

@@ -1,6 +1,9 @@
//! Delete job implementation
use crate::{domain::addressing::SdPathBatch, infra::job::prelude::*};
use crate::{
domain::addressing::SdPathBatch,
infra::job::{generic_progress::GenericProgress, prelude::*},
};
use serde::{Deserialize, Serialize};
use std::{
path::PathBuf,
@@ -51,19 +54,6 @@ pub struct DeleteJob {
started_at: Instant,
}
/// Delete progress information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeleteProgress {
pub current_file: String,
pub files_deleted: usize,
pub total_files: usize,
pub bytes_deleted: u64,
pub total_bytes: u64,
pub current_operation: String,
pub estimated_remaining: Option<Duration>,
}
impl JobProgress for DeleteProgress {}
impl Job for DeleteJob {
const NAME: &'static str = "delete_files";
@@ -84,14 +74,21 @@ impl JobHandler for DeleteJob {
type Output = DeleteOutput;
async fn run(&mut self, ctx: JobContext<'_>) -> JobResult<Self::Output> {
let total_files = self.targets.paths.len();
let mode_str = match self.mode {
DeleteMode::Trash => "trash",
DeleteMode::Permanent => "permanent",
DeleteMode::Secure => "secure",
};
ctx.log(format!(
"Starting {} deletion of {} files",
match self.mode {
DeleteMode::Trash => "trash",
DeleteMode::Permanent => "permanent",
DeleteMode::Secure => "secure",
},
self.targets.paths.len()
mode_str, total_files
));
// Phase: Preparing
ctx.progress(Progress::Indeterminate(
format!("Validating {} targets", total_files),
));
// Safety check for permanent deletion
@@ -106,6 +103,20 @@ impl JobHandler for DeleteJob {
// Validate targets exist (only for local paths)
self.validate_targets(&ctx).await?;
// Phase: Resolving paths
ctx.progress(Progress::Indeterminate("Resolving paths".to_string()));
// Resolve Content paths to Physical paths before strategy selection
let mut resolved = Vec::with_capacity(self.targets.paths.len());
for path in &self.targets.paths {
resolved.push(
path.resolve_in_job(&ctx)
.await
.map_err(|e| JobError::execution(format!("Failed to resolve path: {e}")))?,
);
}
self.targets = SdPathBatch::new(resolved);
// Select strategy based on path topology
let volume_manager = ctx.volume_manager();
let strategy =
@@ -116,6 +127,11 @@ impl JobHandler for DeleteJob {
DeleteStrategyRouter::describe_strategy(&self.targets.paths).await;
ctx.log(format!("Using strategy: {}", strategy_description));
// Phase: Deleting
ctx.progress(Progress::Indeterminate(
format!("Deleting {} files ({})", total_files, mode_str),
));
// Execute deletion using selected strategy
let results = strategy
.execute(&ctx, &self.targets.paths, self.mode.clone())
@@ -140,6 +156,19 @@ impl JobHandler for DeleteJob {
})
.collect();
// Phase: Complete
ctx.progress(Progress::Generic(
GenericProgress::new(
1.0,
"Complete",
format!("{} deleted, {} failed", deleted_count, failed_count),
)
.with_completion(total_files as u64, total_files as u64)
.with_bytes(total_bytes, total_bytes)
.with_performance(0.0, None, Some(self.started_at.elapsed()))
.with_errors(failed_count as u64, 0),
));
ctx.log(format!(
"Delete operation completed: {} deleted, {} failed",
deleted_count, failed_count

View File

@@ -102,6 +102,31 @@ impl LibraryAction for LocationAddAction {
.await
.map_err(|e| ActionError::Internal(e.to_string()))?;
// Register the new location with the filesystem watcher so changes
// (creates, deletes, renames) are detected in real-time.
// Without this, the watcher only learns about locations at startup.
if let Some(local_path) = self.input.path.as_local_path() {
if let Some(fs_watcher) = context.get_fs_watcher().await {
use crate::ops::indexing::handlers::LocationMeta;
use crate::ops::indexing::RuleToggles;
let root_path = tokio::fs::canonicalize(local_path)
.await
.unwrap_or_else(|_| local_path.to_path_buf());
let root_path = crate::common::utils::strip_windows_extended_prefix(root_path);
let meta = LocationMeta {
id: location_id,
library_id: library.id(),
root_path,
rule_toggles: RuleToggles::default(),
};
if let Err(e) = fs_watcher.watch_location(meta).await {
tracing::warn!("Failed to register location with watcher: {}", e);
}
}
}
// Parse the job ID from the string returned by add_location
let job_id = if !job_id_string.is_empty() {
Some(

View File

@@ -43,13 +43,24 @@ impl LibraryAction for LocationRemoveAction {
library: std::sync::Arc<crate::library::Library>,
context: Arc<CoreContext>,
) -> Result<Self::Output, ActionError> {
// Remove the location
// Remove the location from DB
let location_manager = LocationManager::new(context.events.as_ref().clone());
location_manager
.remove_location(&library, self.input.location_id)
.await
.map_err(|e| ActionError::Internal(e.to_string()))?;
// Unwatch the location from the filesystem watcher
if let Some(watcher) = context.get_fs_watcher().await {
if let Err(e) = watcher.unwatch_location(self.input.location_id).await {
tracing::warn!(
"Failed to unwatch location {}: {}",
self.input.location_id,
e
);
}
}
Ok(LocationRemoveOutput::new(self.input.location_id, None))
}

View File

@@ -296,18 +296,7 @@ impl super::FilesystemHandler for RefsHandler {
}
fn contains_path(&self, volume: &Volume, path: &std::path::Path) -> bool {
// Strip Windows extended path prefix (\\?\) produced by canonicalize()
let normalized_path = if let Some(path_str) = path.to_str() {
if path_str.starts_with("\\\\?\\UNC\\") {
PathBuf::from(format!("\\\\{}", &path_str[8..]))
} else if let Some(stripped) = path_str.strip_prefix("\\\\?\\") {
PathBuf::from(stripped)
} else {
path.to_path_buf()
}
} else {
path.to_path_buf()
};
let normalized_path = crate::common::utils::strip_windows_extended_prefix(path.to_path_buf());
if normalized_path.starts_with(&volume.mount_point) {
return true;

View File

@@ -302,10 +302,10 @@ export function SpaceItem({
? { ...sortableAttributes, ...sortableListeners }
: {})}
className={clsx(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium transition-colors relative cursor-default",
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium transition-colors relative cursor-pointer",
isActive
? "bg-sidebar-selected/30 text-sidebar-ink"
: className || "text-sidebar-inkDull",
: [className || "text-sidebar-inkDull", "hover:text-sidebar-ink hover:bg-sidebar-selected/20"],
showDropHighlight && "bg-accent/10",
)}
>

View File

@@ -0,0 +1,44 @@
import { useCallback } from "react";
import type { File } from "@sd/ts-client";
import { useLibraryMutation } from "../../../contexts/SpacedriveContext";
/**
* Shared hook for delete file operations.
* Used by both useExplorerKeyboard (DEL key) and useFileContextMenu.
*/
export function useDeleteFiles() {
const mutation = useLibraryMutation("files.delete");
const deleteFiles = useCallback(
async (files: File[], permanent: boolean) => {
if (files.length === 0) return false;
if (files.some((f) => !f.sd_path)) return false;
if (mutation.isPending) return false;
const label = permanent ? "Permanently delete" : "Delete";
const suffix = permanent ? " This cannot be undone." : "";
const message =
files.length > 1
? `${label} ${files.length} items?${suffix}`
: `${label} "${files[0].name}"?${suffix}`;
if (!confirm(message)) return false;
try {
await mutation.mutateAsync({
targets: { paths: files.map((f) => f.sd_path) },
permanent,
recursive: true,
});
return true;
} catch (err) {
console.error("Failed to delete:", err);
alert(`Failed to delete: ${err}`);
return false;
}
},
[mutation],
);
return { deleteFiles, isPending: mutation.isPending };
}

View File

@@ -8,6 +8,7 @@ import { useKeybind } from "../../../hooks/useKeybind";
import { useKeybindScope } from "../../../hooks/useKeybindScope";
import { useClipboard } from "../../../hooks/useClipboard";
import { useFileOperationDialog } from "../../../components/modals/FileOperationModal";
import { useDeleteFiles } from "./useDeleteFiles";
import { isInputFocused } from "../../../util/keybinds/platform";
export function useExplorerKeyboard() {
@@ -36,6 +37,7 @@ export function useExplorerKeyboard() {
} = useSelection();
const clipboard = useClipboard();
const openFileOperation = useFileOperationDialog();
const { deleteFiles, isPending: isDeleting } = useDeleteFiles();
// Activate explorer keybind scope when this hook is active
useKeybindScope("explorer");
@@ -160,6 +162,26 @@ export function useExplorerKeyboard() {
{ enabled: selectedFiles.length === 1 },
);
// Delete: Move to trash
useKeybind(
"explorer.delete",
async () => {
const ok = await deleteFiles(selectedFiles, false);
if (ok) clearSelection();
},
{ enabled: selectedFiles.length > 0 && !isDeleting },
);
// Permanent Delete: Shift+Delete / Cmd+Alt+Backspace
useKeybind(
"explorer.permanentDelete",
async () => {
const ok = await deleteFiles(selectedFiles, true);
if (ok) clearSelection();
},
{ enabled: selectedFiles.length > 0 && !isDeleting },
);
useEffect(() => {
const handleKeyDown = async (e: KeyboardEvent) => {
// Skip all keyboard shortcuts if renaming or typing in an input

View File

@@ -33,6 +33,7 @@ import { useClipboard } from "../../../hooks/useClipboard";
import { useFileOperationDialog } from "../../../components/modals/FileOperationModal";
import { useSelection } from "../SelectionContext";
import { useOpenWith } from "../../../hooks/useOpenWith";
import { useDeleteFiles } from "./useDeleteFiles";
interface UseFileContextMenuProps {
file?: File | null;
@@ -48,7 +49,7 @@ export function useFileContextMenu({
const { navigateToPath, currentPath } = useExplorer();
const platform = usePlatform();
const copyFiles = useLibraryMutation("files.copy");
const deleteFiles = useLibraryMutation("files.delete");
const { deleteFiles } = useDeleteFiles();
const createFolder = useLibraryMutation("files.createFolder");
const { runJob } = useJobDispatch();
const clipboard = useClipboard();
@@ -532,60 +533,7 @@ export function useFileContextMenu({
: "Delete",
onClick: async () => {
const targets = getTargetFiles();
if (targets.length === 0) {
console.warn("Cannot delete virtual files");
return;
}
const message =
targets.length > 1
? `Delete ${targets.length} items?`
: `Delete "${file?.name ?? "this file"}"?`;
if (confirm(message)) {
console.log(
"Deleting files:",
targets.map((f) => f.name),
);
try {
const result = await deleteFiles.mutateAsync({
targets: {
paths: targets.map((f) => f.sd_path),
},
permanent: false,
recursive: true,
});
console.log("Delete result:", result);
// Check if it's a confirmation request
if (
result &&
typeof result === "object" &&
"NeedsConfirmation" in result
) {
console.log(
"Delete needs confirmation:",
result,
);
alert(
"Delete confirmation UI not implemented yet",
);
} else if (
result &&
typeof result === "object" &&
"job_id" in result
) {
console.log(
"Delete job started:",
result.job_id,
);
}
} catch (err) {
console.error("Failed to delete:", err);
alert(`Failed to delete: ${err}`);
}
}
await deleteFiles(targets, false);
},
keybind: "⌘⌫",
variant: "danger" as const,

View File

Binary file not shown.

View File

@@ -497,9 +497,9 @@ This abstraction simplifies cross-device operations into type-safe function call
\paragraph{Universal URI Scheme}
To make these addressing modes accessible and ergonomic for clients, APIs, and command-line interfaces, Spacedrive establishes a unified string-based URI scheme. This allows any path, whether physical or content-based, to be represented as a simple, standardized string:
\begin{itemize}[noitemsep, topsep=0pt]
\item \textbf{Physical Path}: A physical location is represented as \texttt{sd://\textless{}device\_id\textgreater{}/path/to/file}.
\item \textbf{Cloud Path}: A cloud volume location is represented as \texttt{sd://cloud/\textless{}volume\_id\textgreater{}/path/to/file}.
\item \textbf{Content Path}: A content-aware handle is represented as \texttt{sd://content/\textless{}content\_id\textgreater{}}.
\item \textbf{Physical Path}: A physical location is represented as\newline \texttt{sd://\textless{}device\_id\textgreater{}/path/to/file}.
\item \textbf{Cloud Path}: A cloud volume location is represented as\newline \texttt{sd://cloud/\textless{}volume\_id\textgreater{}/path/to/file}.
\item \textbf{Content Path}: A content-aware handle is represented as\newline \texttt{sd://content/\textless{}content\_id\textgreater{}}.
\end{itemize}
This design decouples clients from the complexity of path resolution. A user interface or script can operate entirely on these URI strings, passing them to the core engine, which is then responsible for parsing the URI and dispatching the appropriate resolution logic.
@@ -1921,10 +1921,10 @@ Spacedrive's architecture allows any core to serve files publicly. The distincti
\begin{itemize}
\item \textbf{Personal Device Core}: A user can serve files directly from their laptop or NAS, but this requires manual setup like port forwarding on their router and configuring Dynamic DNS to handle changing IP addresses.
\item \textbf{Self-Hosted Core}: An organization can deploy a core on their own server with a static IP and manage their own SSL certificates and web server configuration.
\item \textbf{Spacedrive Cloud (Managed Core)}: For convenience, the Spacedrive Cloud service automates this. It runs the user's core behind a managed load balancer that handles SSL termination and provides a stable public URL (e.g., \texttt{https://sd.app/user/file.pdf}). This is a feature of the managed service, not a special capability of the core software itself.
\item \textbf{Spacedrive Cloud (Managed Core)}: For convenience, the Spacedrive Cloud service automates this. It runs the user's core behind a managed load balancer that handles SSL termination and provides a stable public URL (e.g., \texttt{https://\allowbreak sd.app/\allowbreak user/\allowbreak file.pdf}). This is a feature of the managed service, not a special capability of the core software itself.
\end{itemize}
\begin{lstlisting}[language=text, caption=Public sharing URL examples]
\begin{lstlisting}[style=shellstyle, language=text, caption=Public sharing URL examples]
# Via Spacedrive Cloud (automatic SSL + CDN)
https://sd.app/user/file.pdf