mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-05-18 05:15:16 -04:00
Add misc UI and ephemeral watching (wip)
This commit is contained in:
BIN
Cargo.lock
generated
BIN
Cargo.lock
generated
Binary file not shown.
BIN
apps/tauri/Spacedrive.icon/Assets/Ball.png
Normal file
BIN
apps/tauri/Spacedrive.icon/Assets/Ball.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 214 KiB |
50
apps/tauri/Spacedrive.icon/icon.json
Normal file
50
apps/tauri/Spacedrive.icon/icon.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"fill" : {
|
||||
"linear-gradient" : [
|
||||
"display-p3:0.99810,0.99038,1.00000,1.00000",
|
||||
"display-p3:0.99810,0.99038,1.00000,1.00000"
|
||||
],
|
||||
"orientation" : {
|
||||
"start" : {
|
||||
"x" : 0.5,
|
||||
"y" : 1
|
||||
},
|
||||
"stop" : {
|
||||
"x" : 0.5,
|
||||
"y" : 0.3
|
||||
}
|
||||
}
|
||||
},
|
||||
"groups" : [
|
||||
{
|
||||
"layers" : [
|
||||
{
|
||||
"glass" : true,
|
||||
"image-name" : "Ball.png",
|
||||
"name" : "Ball",
|
||||
"position" : {
|
||||
"scale" : 2,
|
||||
"translation-in-points" : [
|
||||
1.7218333746113785,
|
||||
2.7640092574830533
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"shadow" : {
|
||||
"kind" : "neutral",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"translucency" : {
|
||||
"enabled" : true,
|
||||
"value" : 0.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms" : {
|
||||
"circles" : [
|
||||
"watchOS"
|
||||
],
|
||||
"squares" : "shared"
|
||||
}
|
||||
}
|
||||
27
apps/tauri/scripts/build-macos-icon.sh
Executable file
27
apps/tauri/scripts/build-macos-icon.sh
Executable file
@@ -0,0 +1,27 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||
TAURI_ROOT="$SCRIPT_DIR/.."
|
||||
ICON_SOURCE="$TAURI_ROOT/Spacedrive.icon"
|
||||
GEN_DIR="$TAURI_ROOT/src-tauri/gen"
|
||||
|
||||
# Create gen directory if it doesn't exist
|
||||
mkdir -p "$GEN_DIR"
|
||||
|
||||
# Compile .icon to Assets.car using actool
|
||||
echo "Compiling Spacedrive.icon to Assets.car..."
|
||||
xcrun actool "$ICON_SOURCE" \
|
||||
--compile "$GEN_DIR" \
|
||||
--output-format human-readable-text \
|
||||
--notices --warnings --errors \
|
||||
--output-partial-info-plist "$GEN_DIR/partial.plist" \
|
||||
--app-icon Spacedrive \
|
||||
--include-all-app-icons \
|
||||
--enable-on-demand-resources NO \
|
||||
--development-region en \
|
||||
--target-device mac \
|
||||
--minimum-deployment-target 11.0 \
|
||||
--platform macosx
|
||||
|
||||
echo "Successfully generated Assets.car and Spacedrive.icns"
|
||||
2
apps/tauri/src-tauri/.gitignore
vendored
Normal file
2
apps/tauri/src-tauri/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Generated icon assets
|
||||
gen/
|
||||
@@ -1,8 +1,12 @@
|
||||
[package]
|
||||
name = "spacedrive-tauri"
|
||||
name = "spacedrive"
|
||||
version = "2.0.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "Spacedrive"
|
||||
path = "src/main.rs"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2.0", features = [] }
|
||||
|
||||
|
||||
10
apps/tauri/src-tauri/Info.plist
Normal file
10
apps/tauri/src-tauri/Info.plist
Normal file
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>Spacedrive</string>
|
||||
<key>CFBundleIconName</key>
|
||||
<string>Spacedrive</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,3 +1,66 @@
|
||||
use std::process::Command;
|
||||
|
||||
fn main() {
|
||||
// Compile .icon to Assets.car on macOS
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let project_root = std::env::var("CARGO_MANIFEST_DIR")
|
||||
.expect("CARGO_MANIFEST_DIR not set");
|
||||
let icon_source = format!("{}/../Spacedrive.icon", project_root);
|
||||
let gen_dir = format!("{}/gen", project_root);
|
||||
|
||||
// Create gen directory
|
||||
std::fs::create_dir_all(&gen_dir).expect("Failed to create gen directory");
|
||||
|
||||
// Check if .icon file exists
|
||||
if std::path::Path::new(&icon_source).exists() {
|
||||
println!("cargo:rerun-if-changed={}", icon_source);
|
||||
|
||||
// Run actool to compile .icon to Assets.car
|
||||
let output = Command::new("xcrun")
|
||||
.args(&[
|
||||
"actool",
|
||||
&icon_source,
|
||||
"--compile",
|
||||
&gen_dir,
|
||||
"--output-format",
|
||||
"human-readable-text",
|
||||
"--notices",
|
||||
"--warnings",
|
||||
"--errors",
|
||||
"--output-partial-info-plist",
|
||||
&format!("{}/partial.plist", gen_dir),
|
||||
"--app-icon",
|
||||
"Spacedrive",
|
||||
"--include-all-app-icons",
|
||||
"--enable-on-demand-resources",
|
||||
"NO",
|
||||
"--development-region",
|
||||
"en",
|
||||
"--target-device",
|
||||
"mac",
|
||||
"--minimum-deployment-target",
|
||||
"11.0",
|
||||
"--platform",
|
||||
"macosx",
|
||||
])
|
||||
.output()
|
||||
.expect("Failed to execute actool");
|
||||
|
||||
if !output.status.success() {
|
||||
eprintln!(
|
||||
"actool failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"Successfully compiled Spacedrive.icon to Assets.car"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
println!("cargo:warning=Spacedrive.icon not found at {}", icon_source);
|
||||
}
|
||||
}
|
||||
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
Binary file not shown.
@@ -57,6 +57,9 @@
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"resources": {
|
||||
"gen/**/*": "./"
|
||||
},
|
||||
"fileAssociations": [
|
||||
{
|
||||
"ext": ["memory"],
|
||||
@@ -73,7 +76,9 @@
|
||||
},
|
||||
"macOS": {
|
||||
"minimumSystemVersion": "10.15",
|
||||
"signingIdentity": "Apple Development: James Pine (TU2EDB6U4N)"
|
||||
"signingIdentity": "-",
|
||||
"infoPlist": "Info.plist",
|
||||
"frameworks": ["../apps/.deps/Spacedrive.framework"]
|
||||
},
|
||||
"windows": {
|
||||
"webviewInstallMode": {
|
||||
|
||||
BIN
core/memory_file
BIN
core/memory_file
Binary file not shown.
@@ -22,6 +22,7 @@ pub struct CoreContext {
|
||||
pub action_manager: Arc<RwLock<Option<Arc<ActionManager>>>>,
|
||||
pub networking: Arc<RwLock<Option<Arc<NetworkingService>>>>,
|
||||
pub plugin_manager: Arc<RwLock<Option<Arc<RwLock<crate::infra::extension::PluginManager>>>>>,
|
||||
pub location_watcher: Arc<RwLock<Option<Arc<crate::service::watcher::LocationWatcher>>>>,
|
||||
// Ephemeral index cache for unmanaged paths
|
||||
pub ephemeral_index_cache: Arc<EphemeralIndexCache>,
|
||||
// Job logging configuration
|
||||
@@ -49,6 +50,7 @@ impl CoreContext {
|
||||
action_manager: Arc::new(RwLock::new(None)),
|
||||
networking: Arc::new(RwLock::new(None)),
|
||||
plugin_manager: Arc::new(RwLock::new(None)),
|
||||
location_watcher: Arc::new(RwLock::new(None)),
|
||||
ephemeral_index_cache: Arc::new(
|
||||
EphemeralIndexCache::new().expect("Failed to create ephemeral index cache"),
|
||||
),
|
||||
@@ -100,6 +102,16 @@ impl CoreContext {
|
||||
*self.networking.write().await = Some(networking);
|
||||
}
|
||||
|
||||
/// Helper method for services to get the location watcher
|
||||
pub async fn get_location_watcher(&self) -> Option<Arc<crate::service::watcher::LocationWatcher>> {
|
||||
self.location_watcher.read().await.clone()
|
||||
}
|
||||
|
||||
/// Method for Core to set location watcher after it's initialized
|
||||
pub async fn set_location_watcher(&self, watcher: Arc<crate::service::watcher::LocationWatcher>) {
|
||||
*self.location_watcher.write().await = Some(watcher);
|
||||
}
|
||||
|
||||
/// Helper method to get the action manager
|
||||
pub async fn get_action_manager(&self) -> Option<Arc<ActionManager>> {
|
||||
self.action_manager.read().await.clone()
|
||||
|
||||
@@ -91,7 +91,7 @@ impl JobManager {
|
||||
/// Dispatch a job for execution
|
||||
pub async fn dispatch<J>(&self, job: J) -> JobResult<JobHandle>
|
||||
where
|
||||
J: Job + JobHandler,
|
||||
J: Job + JobHandler + DynJob,
|
||||
{
|
||||
self.dispatch_with_priority(job, JobPriority::NORMAL, None)
|
||||
.await
|
||||
@@ -165,34 +165,42 @@ impl JobManager {
|
||||
action_context: Option<ActionContext>,
|
||||
) -> JobResult<JobHandle> {
|
||||
let job_id = JobId::new();
|
||||
info!("Dispatching job {} ({}): {}", job_id, job_name, job_name);
|
||||
let should_persist = erased_job.should_persist();
|
||||
|
||||
// Serialize job state for database
|
||||
let state = erased_job.serialize_state()?;
|
||||
info!(
|
||||
"Dispatching job {} ({}): {} [persist: {}]",
|
||||
job_id, job_name, job_name, should_persist
|
||||
);
|
||||
|
||||
// Create database record
|
||||
let job_model = database::jobs::ActiveModel {
|
||||
id: Set(job_id.to_string()),
|
||||
name: Set(job_name.to_string()),
|
||||
state: Set(state),
|
||||
status: Set(JobStatus::Queued.to_string()),
|
||||
priority: Set(priority.0),
|
||||
progress_type: Set(None),
|
||||
progress_data: Set(None),
|
||||
parent_job_id: Set(None),
|
||||
created_at: Set(Utc::now()),
|
||||
started_at: Set(None),
|
||||
completed_at: Set(None),
|
||||
paused_at: Set(None),
|
||||
error_message: Set(None),
|
||||
warnings: Set(None),
|
||||
non_critical_errors: Set(None),
|
||||
metrics: Set(None),
|
||||
action_context: Set(None),
|
||||
action_type: Set(None),
|
||||
};
|
||||
// Only persist to database if the job should be persisted
|
||||
if should_persist {
|
||||
// Serialize job state for database
|
||||
let state = erased_job.serialize_state()?;
|
||||
|
||||
job_model.insert(self.db.conn()).await?;
|
||||
// Create database record
|
||||
let job_model = database::jobs::ActiveModel {
|
||||
id: Set(job_id.to_string()),
|
||||
name: Set(job_name.to_string()),
|
||||
state: Set(state),
|
||||
status: Set(JobStatus::Queued.to_string()),
|
||||
priority: Set(priority.0),
|
||||
progress_type: Set(None),
|
||||
progress_data: Set(None),
|
||||
parent_job_id: Set(None),
|
||||
created_at: Set(Utc::now()),
|
||||
started_at: Set(None),
|
||||
completed_at: Set(None),
|
||||
paused_at: Set(None),
|
||||
error_message: Set(None),
|
||||
warnings: Set(None),
|
||||
non_critical_errors: Set(None),
|
||||
metrics: Set(None),
|
||||
action_context: Set(None),
|
||||
action_type: Set(None),
|
||||
};
|
||||
|
||||
job_model.insert(self.db.conn()).await?;
|
||||
}
|
||||
|
||||
// Create channels
|
||||
let (status_tx, status_rx) = watch::channel(JobStatus::Queued);
|
||||
@@ -203,6 +211,7 @@ impl JobManager {
|
||||
let latest_progress = Arc::new(Mutex::new(None));
|
||||
|
||||
// Create progress forwarding task
|
||||
// For ephemeral jobs, skip database updates and event emission
|
||||
let broadcast_tx_clone = broadcast_tx.clone();
|
||||
let latest_progress_clone = latest_progress.clone();
|
||||
let event_bus = self.context.events.clone();
|
||||
@@ -217,6 +226,11 @@ impl JobManager {
|
||||
*latest_progress_clone.lock().await = Some(progress.clone());
|
||||
let _ = broadcast_tx_clone.send(progress.clone());
|
||||
|
||||
// Skip event updates for ephemeral jobs
|
||||
if !should_persist {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Throttle JobProgress events to prevent flooding the event bus
|
||||
let now = std::time::Instant::now();
|
||||
if now.duration_since(last_emit) < throttle_duration {
|
||||
@@ -337,54 +351,57 @@ impl JobManager {
|
||||
let status = *status_rx.borrow();
|
||||
match status {
|
||||
JobStatus::Completed => {
|
||||
// Get the final output from the handle before removing the job
|
||||
let output = {
|
||||
let jobs = running_jobs.read().await;
|
||||
if let Some(job) = jobs.get(&job_id_clone) {
|
||||
let result = job.handle.output.lock().await.clone();
|
||||
match result {
|
||||
Some(Ok(output)) => output,
|
||||
Some(Err(_)) => JobOutput::Success,
|
||||
None => JobOutput::Success,
|
||||
}
|
||||
} else {
|
||||
JobOutput::Success
|
||||
}
|
||||
};
|
||||
|
||||
// Emit completion event with the job's output
|
||||
event_bus.emit(Event::JobCompleted {
|
||||
job_id: job_id_clone.to_string(),
|
||||
job_type: job_type_str.clone(),
|
||||
output,
|
||||
});
|
||||
|
||||
// Trigger library statistics recalculation after job completion
|
||||
let library_id_for_stats = library_id_clone;
|
||||
let context_for_stats = context.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Some(library) = context_for_stats
|
||||
.libraries()
|
||||
.await
|
||||
.get_library(library_id_for_stats)
|
||||
.await
|
||||
{
|
||||
if let Err(e) = library.recalculate_statistics().await {
|
||||
warn!(
|
||||
library_id = %library_id_for_stats,
|
||||
job_id = %job_id_clone,
|
||||
error = %e,
|
||||
"Failed to trigger library statistics recalculation after job completion"
|
||||
);
|
||||
// Only emit events and trigger statistics for persistent jobs
|
||||
if should_persist {
|
||||
// Get the final output from the handle before removing the job
|
||||
let output = {
|
||||
let jobs = running_jobs.read().await;
|
||||
if let Some(job) = jobs.get(&job_id_clone) {
|
||||
let result = job.handle.output.lock().await.clone();
|
||||
match result {
|
||||
Some(Ok(output)) => output,
|
||||
Some(Err(_)) => JobOutput::Success,
|
||||
None => JobOutput::Success,
|
||||
}
|
||||
} else {
|
||||
debug!(
|
||||
library_id = %library_id_for_stats,
|
||||
job_id = %job_id_clone,
|
||||
"Triggered library statistics recalculation after job completion"
|
||||
);
|
||||
JobOutput::Success
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Emit completion event with the job's output
|
||||
event_bus.emit(Event::JobCompleted {
|
||||
job_id: job_id_clone.to_string(),
|
||||
job_type: job_type_str.clone(),
|
||||
output,
|
||||
});
|
||||
|
||||
// Trigger library statistics recalculation after job completion
|
||||
let library_id_for_stats = library_id_clone;
|
||||
let context_for_stats = context.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Some(library) = context_for_stats
|
||||
.libraries()
|
||||
.await
|
||||
.get_library(library_id_for_stats)
|
||||
.await
|
||||
{
|
||||
if let Err(e) = library.recalculate_statistics().await {
|
||||
warn!(
|
||||
library_id = %library_id_for_stats,
|
||||
job_id = %job_id_clone,
|
||||
error = %e,
|
||||
"Failed to trigger library statistics recalculation after job completion"
|
||||
);
|
||||
} else {
|
||||
debug!(
|
||||
library_id = %library_id_for_stats,
|
||||
job_id = %job_id_clone,
|
||||
"Triggered library statistics recalculation after job completion"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Remove from running jobs
|
||||
running_jobs.write().await.remove(&job_id_clone);
|
||||
@@ -395,23 +412,27 @@ impl JobManager {
|
||||
break;
|
||||
}
|
||||
JobStatus::Failed => {
|
||||
// Emit failure event
|
||||
event_bus.emit(Event::JobFailed {
|
||||
job_id: job_id_clone.to_string(),
|
||||
job_type: job_type_str.clone(),
|
||||
error: "Job failed".to_string(),
|
||||
});
|
||||
// Only emit events for persistent jobs
|
||||
if should_persist {
|
||||
event_bus.emit(Event::JobFailed {
|
||||
job_id: job_id_clone.to_string(),
|
||||
job_type: job_type_str.clone(),
|
||||
error: "Job failed".to_string(),
|
||||
});
|
||||
}
|
||||
// Remove from running jobs
|
||||
running_jobs.write().await.remove(&job_id_clone);
|
||||
info!("Job {} failed and removed from running jobs", job_id_clone);
|
||||
break;
|
||||
}
|
||||
JobStatus::Cancelled => {
|
||||
// Emit cancellation event
|
||||
event_bus.emit(Event::JobCancelled {
|
||||
job_id: job_id_clone.to_string(),
|
||||
job_type: job_type_str.clone(),
|
||||
});
|
||||
// Only emit events for persistent jobs
|
||||
if should_persist {
|
||||
event_bus.emit(Event::JobCancelled {
|
||||
job_id: job_id_clone.to_string(),
|
||||
job_type: job_type_str.clone(),
|
||||
});
|
||||
}
|
||||
// Remove from running jobs
|
||||
running_jobs.write().await.remove(&job_id_clone);
|
||||
info!(
|
||||
@@ -439,54 +460,68 @@ impl JobManager {
|
||||
action_context: Option<ActionContext>,
|
||||
) -> JobResult<JobHandle>
|
||||
where
|
||||
J: Job + JobHandler,
|
||||
J: Job + JobHandler + DynJob,
|
||||
{
|
||||
let job_id = JobId::new();
|
||||
let should_persist = job.should_persist();
|
||||
|
||||
if let Some(ref ctx) = action_context {
|
||||
info!(
|
||||
"Dispatching job {}: {} (from action: {})",
|
||||
"Dispatching job {}: {} (from action: {}) [persist: {}]",
|
||||
job_id,
|
||||
J::NAME,
|
||||
ctx.action_type
|
||||
ctx.action_type,
|
||||
should_persist
|
||||
);
|
||||
} else {
|
||||
info!("Dispatching job {}: {}", job_id, J::NAME);
|
||||
info!(
|
||||
"Dispatching job {}: {} [persist: {}]",
|
||||
job_id,
|
||||
J::NAME,
|
||||
should_persist
|
||||
);
|
||||
}
|
||||
|
||||
// Serialize job state
|
||||
let state =
|
||||
rmp_serde::to_vec(&job).map_err(|e| JobError::serialization(format!("{}", e)))?;
|
||||
// Only persist to database if the job should be persisted
|
||||
if should_persist {
|
||||
// Serialize job state
|
||||
let state =
|
||||
rmp_serde::to_vec(&job).map_err(|e| JobError::serialization(format!("{}", e)))?;
|
||||
|
||||
// Serialize action context if provided
|
||||
let serialized_action_context = if let Some(ref ctx) = action_context {
|
||||
Some(rmp_serde::to_vec(ctx).map_err(|e| JobError::serialization(format!("{}", e)))?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Serialize action context if provided
|
||||
let serialized_action_context = if let Some(ref ctx) = action_context {
|
||||
Some(
|
||||
rmp_serde::to_vec(ctx)
|
||||
.map_err(|e| JobError::serialization(format!("{}", e)))?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Create database record
|
||||
let job_model = database::jobs::ActiveModel {
|
||||
id: Set(job_id.to_string()),
|
||||
name: Set(J::NAME.to_string()),
|
||||
state: Set(state),
|
||||
status: Set(JobStatus::Queued.to_string()),
|
||||
priority: Set(priority.0),
|
||||
progress_type: Set(None),
|
||||
progress_data: Set(None),
|
||||
parent_job_id: Set(None),
|
||||
created_at: Set(Utc::now()),
|
||||
started_at: Set(None),
|
||||
completed_at: Set(None),
|
||||
paused_at: Set(None),
|
||||
error_message: Set(None),
|
||||
warnings: Set(None),
|
||||
non_critical_errors: Set(None),
|
||||
metrics: Set(None),
|
||||
action_context: Set(serialized_action_context),
|
||||
action_type: Set(action_context.as_ref().map(|ctx| ctx.action_type.clone())),
|
||||
};
|
||||
// Create database record
|
||||
let job_model = database::jobs::ActiveModel {
|
||||
id: Set(job_id.to_string()),
|
||||
name: Set(J::NAME.to_string()),
|
||||
state: Set(state),
|
||||
status: Set(JobStatus::Queued.to_string()),
|
||||
priority: Set(priority.0),
|
||||
progress_type: Set(None),
|
||||
progress_data: Set(None),
|
||||
parent_job_id: Set(None),
|
||||
created_at: Set(Utc::now()),
|
||||
started_at: Set(None),
|
||||
completed_at: Set(None),
|
||||
paused_at: Set(None),
|
||||
error_message: Set(None),
|
||||
warnings: Set(None),
|
||||
non_critical_errors: Set(None),
|
||||
metrics: Set(None),
|
||||
action_context: Set(serialized_action_context),
|
||||
action_type: Set(action_context.as_ref().map(|ctx| ctx.action_type.clone())),
|
||||
};
|
||||
|
||||
job_model.insert(self.db.conn()).await?;
|
||||
job_model.insert(self.db.conn()).await?;
|
||||
}
|
||||
|
||||
// Create channels
|
||||
let (status_tx, status_rx) = watch::channel(JobStatus::Queued);
|
||||
@@ -497,6 +532,7 @@ impl JobManager {
|
||||
let latest_progress = Arc::new(Mutex::new(None));
|
||||
|
||||
// Create progress forwarding task with batching and throttling
|
||||
// For ephemeral jobs, skip database updates and event emission
|
||||
let broadcast_tx_clone = broadcast_tx.clone();
|
||||
let latest_progress_clone = latest_progress.clone();
|
||||
let event_bus = self.context.events.clone();
|
||||
@@ -519,6 +555,11 @@ impl JobManager {
|
||||
// Ignore errors if no one is listening
|
||||
let _ = broadcast_tx_clone.send(progress.clone());
|
||||
|
||||
// Skip database and event updates for ephemeral jobs
|
||||
if !should_persist {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Persist progress to database with throttling
|
||||
if last_db_update.elapsed() >= DB_UPDATE_INTERVAL {
|
||||
if let Err(e) = job_db_clone.update_progress(job_id_clone, &progress).await {
|
||||
@@ -564,12 +605,14 @@ impl JobManager {
|
||||
}
|
||||
|
||||
// Final progress update when channel closes
|
||||
if let Some(final_progress) = &*latest_progress_clone.lock().await {
|
||||
if let Err(e) = job_db_clone
|
||||
.update_progress(job_id_clone, final_progress)
|
||||
.await
|
||||
{
|
||||
debug!("Failed to persist final job progress to database: {}", e);
|
||||
if should_persist {
|
||||
if let Some(final_progress) = &*latest_progress_clone.lock().await {
|
||||
if let Err(e) = job_db_clone
|
||||
.update_progress(job_id_clone, final_progress)
|
||||
.await
|
||||
{
|
||||
debug!("Failed to persist final job progress to database: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -661,54 +704,57 @@ impl JobManager {
|
||||
info!("Job {} status changed to: {:?}", job_id_clone, status);
|
||||
match status {
|
||||
JobStatus::Completed => {
|
||||
// Get the final output from the handle before removing the job
|
||||
let output = {
|
||||
let jobs = running_jobs.read().await;
|
||||
if let Some(job) = jobs.get(&job_id_clone) {
|
||||
job.handle
|
||||
.output
|
||||
.lock()
|
||||
.await
|
||||
.clone()
|
||||
.unwrap_or(Ok(JobOutput::Success))
|
||||
} else {
|
||||
Ok(JobOutput::Success)
|
||||
}
|
||||
};
|
||||
|
||||
// Emit completion event with the job's output
|
||||
event_bus.emit(Event::JobCompleted {
|
||||
job_id: job_id_clone.to_string(),
|
||||
job_type: job_type_str.to_string(),
|
||||
output: output.unwrap_or(JobOutput::Success),
|
||||
});
|
||||
|
||||
// Trigger library statistics recalculation after job completion
|
||||
let library_id_for_stats = library_id_clone;
|
||||
let context_for_stats = context.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Some(library) = context_for_stats
|
||||
.libraries()
|
||||
.await
|
||||
.get_library(library_id_for_stats)
|
||||
.await
|
||||
{
|
||||
if let Err(e) = library.recalculate_statistics().await {
|
||||
warn!(
|
||||
library_id = %library_id_for_stats,
|
||||
job_id = %job_id_clone,
|
||||
error = %e,
|
||||
"Failed to trigger library statistics recalculation after job completion"
|
||||
);
|
||||
// Only emit events and trigger statistics for persistent jobs
|
||||
if should_persist {
|
||||
// Get the final output from the handle before removing the job
|
||||
let output = {
|
||||
let jobs = running_jobs.read().await;
|
||||
if let Some(job) = jobs.get(&job_id_clone) {
|
||||
job.handle
|
||||
.output
|
||||
.lock()
|
||||
.await
|
||||
.clone()
|
||||
.unwrap_or(Ok(JobOutput::Success))
|
||||
} else {
|
||||
debug!(
|
||||
library_id = %library_id_for_stats,
|
||||
job_id = %job_id_clone,
|
||||
"Triggered library statistics recalculation after job completion"
|
||||
);
|
||||
Ok(JobOutput::Success)
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Emit completion event with the job's output
|
||||
event_bus.emit(Event::JobCompleted {
|
||||
job_id: job_id_clone.to_string(),
|
||||
job_type: job_type_str.to_string(),
|
||||
output: output.unwrap_or(JobOutput::Success),
|
||||
});
|
||||
|
||||
// Trigger library statistics recalculation after job completion
|
||||
let library_id_for_stats = library_id_clone;
|
||||
let context_for_stats = context.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Some(library) = context_for_stats
|
||||
.libraries()
|
||||
.await
|
||||
.get_library(library_id_for_stats)
|
||||
.await
|
||||
{
|
||||
if let Err(e) = library.recalculate_statistics().await {
|
||||
warn!(
|
||||
library_id = %library_id_for_stats,
|
||||
job_id = %job_id_clone,
|
||||
error = %e,
|
||||
"Failed to trigger library statistics recalculation after job completion"
|
||||
);
|
||||
} else {
|
||||
debug!(
|
||||
library_id = %library_id_for_stats,
|
||||
job_id = %job_id_clone,
|
||||
"Triggered library statistics recalculation after job completion"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Remove from running jobs
|
||||
running_jobs.write().await.remove(&job_id_clone);
|
||||
@@ -719,23 +765,27 @@ impl JobManager {
|
||||
break;
|
||||
}
|
||||
JobStatus::Failed => {
|
||||
// Emit failure event
|
||||
event_bus.emit(Event::JobFailed {
|
||||
job_id: job_id_clone.to_string(),
|
||||
job_type: job_type_str.to_string(),
|
||||
error: "Job failed".to_string(),
|
||||
});
|
||||
// Only emit events for persistent jobs
|
||||
if should_persist {
|
||||
event_bus.emit(Event::JobFailed {
|
||||
job_id: job_id_clone.to_string(),
|
||||
job_type: job_type_str.to_string(),
|
||||
error: "Job failed".to_string(),
|
||||
});
|
||||
}
|
||||
// Remove from running jobs
|
||||
running_jobs.write().await.remove(&job_id_clone);
|
||||
info!("Job {} failed and removed from running jobs", job_id_clone);
|
||||
break;
|
||||
}
|
||||
JobStatus::Cancelled => {
|
||||
// Emit cancellation event
|
||||
event_bus.emit(Event::JobCancelled {
|
||||
job_id: job_id_clone.to_string(),
|
||||
job_type: job_type_str.to_string(),
|
||||
});
|
||||
// Only emit events for persistent jobs
|
||||
if should_persist {
|
||||
event_bus.emit(Event::JobCancelled {
|
||||
job_id: job_id_clone.to_string(),
|
||||
job_type: job_type_str.to_string(),
|
||||
});
|
||||
}
|
||||
// Remove from running jobs
|
||||
running_jobs.write().await.remove(&job_id_clone);
|
||||
info!(
|
||||
|
||||
@@ -132,4 +132,10 @@ pub trait JobDependencies {
|
||||
pub trait DynJob: Send + Sync {
|
||||
/// Job name for identification
|
||||
fn job_name(&self) -> &'static str;
|
||||
|
||||
/// Whether this job should be persisted to the database
|
||||
/// Returns false for ephemeral jobs that run in the background without persistence
|
||||
fn should_persist(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +151,10 @@ pub trait ErasedJob: Send + Sync + std::fmt::Debug + 'static {
|
||||
) -> Box<dyn sd_task_system::Task<crate::infra::job::error::JobError>>;
|
||||
|
||||
fn serialize_state(&self) -> Result<Vec<u8>, crate::infra::job::error::JobError>;
|
||||
|
||||
fn should_persist(&self) -> bool {
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Information about a job (for display/querying)
|
||||
|
||||
@@ -185,6 +185,11 @@ impl Core {
|
||||
.set_sidecar_manager(services.sidecar_manager.clone())
|
||||
.await;
|
||||
|
||||
// Set location watcher in context so it can be accessed by jobs (for ephemeral watch registration)
|
||||
context
|
||||
.set_location_watcher(services.location_watcher.clone())
|
||||
.await;
|
||||
|
||||
// Auto-load all libraries with context for job manager initialization
|
||||
info!("Loading existing libraries...");
|
||||
let mut loaded_libraries: Vec<Arc<crate::library::Library>> =
|
||||
|
||||
@@ -209,6 +209,10 @@ impl DynJob for IndexerJob {
|
||||
fn job_name(&self) -> &'static str {
|
||||
Self::NAME
|
||||
}
|
||||
|
||||
fn should_persist(&self) -> bool {
|
||||
!self.config.is_ephemeral()
|
||||
}
|
||||
}
|
||||
|
||||
impl JobProgress for IndexerProgress {}
|
||||
@@ -510,10 +514,32 @@ impl JobHandler for IndexerJob {
|
||||
.ephemeral_cache()
|
||||
.mark_indexing_complete(local_path);
|
||||
match &result {
|
||||
Ok(_) => ctx.log(format!(
|
||||
"Marked ephemeral indexing complete for: {}",
|
||||
local_path.display()
|
||||
)),
|
||||
Ok(_) => {
|
||||
ctx.log(format!(
|
||||
"Marked ephemeral indexing complete for: {}",
|
||||
local_path.display()
|
||||
));
|
||||
|
||||
// Automatically add filesystem watch for successfully indexed ephemeral paths
|
||||
// This enables real-time updates when files change in browsed directories
|
||||
if let Some(watcher) = ctx.library().core_context().get_location_watcher().await {
|
||||
if let Err(e) = watcher.add_ephemeral_watch(
|
||||
local_path.to_path_buf(),
|
||||
self.config.rule_toggles
|
||||
).await {
|
||||
ctx.log(format!(
|
||||
"Warning: Failed to add ephemeral watch for {}: {}",
|
||||
local_path.display(),
|
||||
e
|
||||
));
|
||||
} else {
|
||||
ctx.log(format!(
|
||||
"Added ephemeral watch for: {}",
|
||||
local_path.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => ctx.log(format!(
|
||||
"Marked ephemeral indexing complete (job failed: {}) for: {}",
|
||||
e,
|
||||
|
||||
@@ -10,6 +10,6 @@ pub struct ReorderGroupsInput {
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Type)]
|
||||
pub struct ReorderItemsInput {
|
||||
pub group_id: Uuid,
|
||||
pub group_id: Option<Uuid>,
|
||||
pub item_ids: Vec<Uuid>,
|
||||
}
|
||||
|
||||
@@ -145,6 +145,8 @@ impl LocationWorkerMetrics {
|
||||
pub struct WatcherMetrics {
|
||||
/// Total locations being watched
|
||||
pub total_locations: AtomicU64,
|
||||
/// Total ephemeral watches (shallow, non-recursive)
|
||||
pub total_ephemeral_watches: AtomicU64,
|
||||
/// Total events received from filesystem
|
||||
pub total_events_received: AtomicU64,
|
||||
/// Total workers created
|
||||
@@ -179,6 +181,11 @@ impl WatcherMetrics {
|
||||
self.total_locations.store(count as u64, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Update total ephemeral watches count
|
||||
pub fn update_ephemeral_watches(&self, count: usize) {
|
||||
self.total_ephemeral_watches.store(count as u64, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Get event processing rate (events per second)
|
||||
pub fn get_processing_rate(&self) -> f64 {
|
||||
let received = self.total_events_received.load(Ordering::Relaxed);
|
||||
@@ -190,8 +197,9 @@ impl WatcherMetrics {
|
||||
/// Log current metrics
|
||||
pub fn log_metrics(&self) {
|
||||
info!(
|
||||
"Watcher metrics: locations={}, events_received={}, workers_created={}, workers_destroyed={}",
|
||||
"Watcher metrics: locations={}, ephemeral_watches={}, events_received={}, workers_created={}, workers_destroyed={}",
|
||||
self.total_locations.load(Ordering::Relaxed),
|
||||
self.total_ephemeral_watches.load(Ordering::Relaxed),
|
||||
self.total_events_received.load(Ordering::Relaxed),
|
||||
self.total_workers_created.load(Ordering::Relaxed),
|
||||
self.total_workers_destroyed.load(Ordering::Relaxed)
|
||||
|
||||
@@ -575,6 +575,8 @@ impl LocationWatcher {
|
||||
rule_toggles,
|
||||
},
|
||||
);
|
||||
// Update metrics
|
||||
self.metrics.update_ephemeral_watches(watches.len());
|
||||
}
|
||||
|
||||
// Add to file system watcher with NonRecursive mode
|
||||
@@ -592,7 +594,10 @@ impl LocationWatcher {
|
||||
pub async fn remove_ephemeral_watch(&self, path: &Path) -> Result<()> {
|
||||
let watch = {
|
||||
let mut watches = self.ephemeral_watches.write().await;
|
||||
watches.remove(path)
|
||||
let watch = watches.remove(path);
|
||||
// Update metrics
|
||||
self.metrics.update_ephemeral_watches(watches.len());
|
||||
watch
|
||||
};
|
||||
|
||||
if let Some(watch) = watch {
|
||||
@@ -720,6 +725,15 @@ impl LocationWatcher {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Skip locations with IndexMode::None (not persistently indexed)
|
||||
if location.index_mode == "none" {
|
||||
debug!(
|
||||
"Skipping location {} with IndexMode::None (ephemeral browsing only)",
|
||||
location.uuid
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get the full path using PathResolver with timeout
|
||||
let path_result = tokio::time::timeout(
|
||||
std::time::Duration::from_secs(5),
|
||||
@@ -1183,6 +1197,44 @@ impl LocationWatcher {
|
||||
path.display()
|
||||
);
|
||||
|
||||
// Query the location to check its index_mode
|
||||
let libraries = context.libraries().await;
|
||||
let should_watch = if let Some(library) = libraries.get_library(library_id).await {
|
||||
let db = library.db().conn();
|
||||
match crate::infra::db::entities::location::Entity::find()
|
||||
.filter(crate::infra::db::entities::location::Column::Uuid.eq(location_id))
|
||||
.one(db)
|
||||
.await
|
||||
{
|
||||
Ok(Some(location_record)) => {
|
||||
if location_record.index_mode == "none" {
|
||||
debug!(
|
||||
"Skipping newly added location {} with IndexMode::None",
|
||||
location_id
|
||||
);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
warn!("Location {} not found in database", location_id);
|
||||
false
|
||||
}
|
||||
Err(e) => {
|
||||
error!("Failed to query location {}: {}", location_id, e);
|
||||
false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
warn!("Library {} not found for location {}", library_id, location_id);
|
||||
false
|
||||
};
|
||||
|
||||
if !should_watch {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create a temporary LocationWatcher instance for this operation
|
||||
let temp_watcher = LocationWatcher {
|
||||
config: config.clone(),
|
||||
|
||||
@@ -100,6 +100,10 @@ pub fn derive_job(input: TokenStream) -> TokenStream {
|
||||
rmp_serde::to_vec(self)
|
||||
.map_err(|e| crate::infra::job::error::JobError::serialization(format!("{}", e)))
|
||||
}
|
||||
|
||||
fn should_persist(&self) -> bool {
|
||||
<#name as crate::infra::job::traits::DynJob>::should_persist(self)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"overrides": {
|
||||
"@types/node": ">18.18.x",
|
||||
"react": "19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-dom": "19.1.0",
|
||||
"react-router": "=6.20.1",
|
||||
"react-router-dom": "=6.20.1",
|
||||
"@remix-run/router": "=1.13.1",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@phosphor-icons/react": "^2.1.0",
|
||||
"@radix-ui/react-dialog": "^1.0.5",
|
||||
|
||||
@@ -29,7 +29,10 @@ import {
|
||||
PREVIEW_LAYER_ID,
|
||||
} from "./components/QuickPreview";
|
||||
import { createExplorerRouter } from "./router";
|
||||
import { useNormalizedQuery, useLibraryMutation } from "./context";
|
||||
import { useNormalizedQuery, useLibraryMutation, useSpacedriveClient } from "./context";
|
||||
import { useSidebarStore } from "@sd/ts-client";
|
||||
import { useSpaces } from "./components/SpacesSidebar/hooks/useSpaces";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { usePlatform } from "./platform";
|
||||
import type { LocationInfo, SdPath } from "@sd/ts-client";
|
||||
import {
|
||||
@@ -314,8 +317,15 @@ function DndWrapper({ children }: { children: React.ReactNode }) {
|
||||
}),
|
||||
);
|
||||
const addItem = useLibraryMutation("spaces.add_item");
|
||||
const reorderItems = useLibraryMutation("spaces.reorder_items");
|
||||
const reorderGroups = useLibraryMutation("spaces.reorder_groups");
|
||||
const openFileOperation = useFileOperationDialog();
|
||||
const [activeItem, setActiveItem] = useState<any>(null);
|
||||
const client = useSpacedriveClient();
|
||||
const queryClient = useQueryClient();
|
||||
const { currentSpaceId } = useSidebarStore();
|
||||
const { data: spacesData } = useSpaces();
|
||||
const spaces = spacesData?.spaces;
|
||||
|
||||
// Custom collision detection: prefer -top zones over -bottom zones to avoid double lines
|
||||
const customCollision: CollisionDetection = (args) => {
|
||||
@@ -342,13 +352,175 @@ function DndWrapper({ children }: { children: React.ReactNode }) {
|
||||
|
||||
setActiveItem(null);
|
||||
|
||||
if (!over || !active.data.current) return;
|
||||
if (!over) return;
|
||||
|
||||
// Handle sortable reordering (no drag data, just active/over IDs)
|
||||
if (active.id !== over.id && !active.data.current?.type) {
|
||||
console.log("[DnD] Sortable reorder:", {
|
||||
activeId: active.id,
|
||||
overId: over.id,
|
||||
});
|
||||
|
||||
const libraryId = client.getCurrentLibraryId();
|
||||
const currentSpace = spaces?.find((s: any) => s.id === currentSpaceId) ?? spaces?.[0];
|
||||
|
||||
if (!currentSpace || !libraryId) return;
|
||||
|
||||
const queryKey = ['query:spaces.get_layout', libraryId, { space_id: currentSpace.id }];
|
||||
const layout = queryClient.getQueryData(queryKey) as any;
|
||||
|
||||
if (!layout) return;
|
||||
|
||||
// Check if we're reordering groups
|
||||
const groups = layout.groups?.map((g: any) => g.group) || [];
|
||||
const isGroupReorder = groups.some((g: any) => g.id === active.id);
|
||||
|
||||
if (isGroupReorder) {
|
||||
console.log("[DnD] Reordering groups");
|
||||
|
||||
const oldIndex = groups.findIndex((g: any) => g.id === active.id);
|
||||
const newIndex = groups.findIndex((g: any) => g.id === over.id);
|
||||
|
||||
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
|
||||
// Optimistically update the UI
|
||||
const newGroups = [...layout.groups];
|
||||
const [movedGroup] = newGroups.splice(oldIndex, 1);
|
||||
newGroups.splice(newIndex, 0, movedGroup);
|
||||
|
||||
queryClient.setQueryData(queryKey, {
|
||||
...layout,
|
||||
groups: newGroups,
|
||||
});
|
||||
|
||||
// Send reorder mutation
|
||||
try {
|
||||
await reorderGroups.mutateAsync({
|
||||
space_id: currentSpace.id,
|
||||
group_ids: newGroups.map((g: any) => g.group.id),
|
||||
});
|
||||
console.log("[DnD] Group reorder successful");
|
||||
} catch (err) {
|
||||
console.error("[DnD] Group reorder failed:", err);
|
||||
// Revert on error
|
||||
queryClient.setQueryData(queryKey, layout);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Reordering space items
|
||||
if (layout?.space_items) {
|
||||
const items = layout.space_items;
|
||||
const oldIndex = items.findIndex((item: any) => item.id === active.id);
|
||||
|
||||
// Extract item ID from over.id (could be a drop zone ID like "space-item-{id}-top")
|
||||
let overItemId = String(over.id);
|
||||
if (overItemId.startsWith('space-item-')) {
|
||||
// Extract the UUID from "space-item-{uuid}-top/bottom/middle"
|
||||
const parts = overItemId.split('-');
|
||||
// Remove "space" and "item" and the last part (top/bottom/middle)
|
||||
overItemId = parts.slice(2, -1).join('-');
|
||||
}
|
||||
|
||||
const newIndex = items.findIndex((item: any) => item.id === overItemId);
|
||||
|
||||
console.log("[DnD] Reorder space items:", {
|
||||
oldIndex,
|
||||
newIndex,
|
||||
activeId: active.id,
|
||||
extractedOverId: overItemId,
|
||||
});
|
||||
|
||||
if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
|
||||
// Optimistically update the UI
|
||||
const newItems = [...items];
|
||||
const [movedItem] = newItems.splice(oldIndex, 1);
|
||||
newItems.splice(newIndex, 0, movedItem);
|
||||
|
||||
queryClient.setQueryData(queryKey, {
|
||||
...layout,
|
||||
space_items: newItems,
|
||||
});
|
||||
|
||||
// Send reorder mutation
|
||||
try {
|
||||
await reorderItems.mutateAsync({
|
||||
group_id: null, // Space-level items
|
||||
item_ids: newItems.map((item: any) => item.id),
|
||||
});
|
||||
console.log("[DnD] Space items reorder successful");
|
||||
} catch (err) {
|
||||
console.error("[DnD] Space items reorder failed:", err);
|
||||
// Revert on error
|
||||
queryClient.setQueryData(queryKey, layout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!active.data.current) return;
|
||||
|
||||
const dragData = active.data.current;
|
||||
const dropData = over.data.current;
|
||||
|
||||
console.log("[DnD] Drag end:", {
|
||||
dragType: dragData?.type,
|
||||
dropAction: dropData?.action,
|
||||
dropType: dropData?.type,
|
||||
spaceId: dropData?.spaceId,
|
||||
groupId: dropData?.groupId,
|
||||
});
|
||||
|
||||
if (!dragData || dragData.type !== "explorer-file") return;
|
||||
|
||||
// Add to space (root-level drop zones between groups)
|
||||
if (dropData?.action === "add-to-space") {
|
||||
if (!dropData.spaceId) return;
|
||||
|
||||
console.log("[DnD] Adding to space root:", {
|
||||
spaceId: dropData.spaceId,
|
||||
sdPath: dragData.sdPath,
|
||||
});
|
||||
|
||||
try {
|
||||
await addItem.mutateAsync({
|
||||
space_id: dropData.spaceId,
|
||||
group_id: null,
|
||||
item_type: { Path: { sd_path: dragData.sdPath } },
|
||||
});
|
||||
console.log("[DnD] Successfully added to space root");
|
||||
} catch (err) {
|
||||
console.error("[DnD] Failed to add to space:", err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Add to group (empty group drop zone)
|
||||
if (dropData?.action === "add-to-group") {
|
||||
if (!dropData.spaceId || !dropData.groupId) return;
|
||||
|
||||
console.log("[DnD] Adding to group:", {
|
||||
spaceId: dropData.spaceId,
|
||||
groupId: dropData.groupId,
|
||||
sdPath: dragData.sdPath,
|
||||
});
|
||||
|
||||
try {
|
||||
await addItem.mutateAsync({
|
||||
space_id: dropData.spaceId,
|
||||
group_id: dropData.groupId,
|
||||
item_type: { Path: { sd_path: dragData.sdPath } },
|
||||
});
|
||||
console.log("[DnD] Successfully added to group");
|
||||
} catch (err) {
|
||||
console.error("[DnD] Failed to add to group:", err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Insert before/after sidebar items (adds item to space/group)
|
||||
if (
|
||||
dropData?.action === "insert-before" ||
|
||||
@@ -356,15 +528,23 @@ function DndWrapper({ children }: { children: React.ReactNode }) {
|
||||
) {
|
||||
if (!dropData.spaceId) return;
|
||||
|
||||
console.log("[DnD] Inserting item:", {
|
||||
action: dropData.action,
|
||||
spaceId: dropData.spaceId,
|
||||
groupId: dropData.groupId,
|
||||
sdPath: dragData.sdPath,
|
||||
});
|
||||
|
||||
try {
|
||||
await addItem.mutateAsync({
|
||||
space_id: dropData.spaceId,
|
||||
group_id: dropData.groupId || null,
|
||||
item_type: { Path: { sd_path: dragData.sdPath } },
|
||||
});
|
||||
console.log("[DnD] Successfully inserted item");
|
||||
// TODO: Implement proper ordering relative to itemId
|
||||
} catch (err) {
|
||||
console.error("Failed to add item:", err);
|
||||
console.error("[DnD] Failed to add item:", err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -391,27 +571,40 @@ function DndWrapper({ children }: { children: React.ReactNode }) {
|
||||
|
||||
// Drop on space root area (adds to space)
|
||||
if (dropData?.type === "space" && dragData.type === "explorer-file") {
|
||||
console.log("[DnD] Adding to space (type=space):", {
|
||||
spaceId: dropData.spaceId,
|
||||
sdPath: dragData.sdPath,
|
||||
});
|
||||
|
||||
try {
|
||||
await addItem.mutateAsync({
|
||||
space_id: dropData.spaceId,
|
||||
group_id: null,
|
||||
item_type: { Path: { sd_path: dragData.sdPath } },
|
||||
});
|
||||
console.log("[DnD] Successfully added to space");
|
||||
} catch (err) {
|
||||
console.error("Failed to add item:", err);
|
||||
console.error("[DnD] Failed to add item:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Drop on group area (adds to group)
|
||||
if (dropData?.type === "group" && dragData.type === "explorer-file") {
|
||||
console.log("[DnD] Adding to group (type=group):", {
|
||||
spaceId: dropData.spaceId,
|
||||
groupId: dropData.groupId,
|
||||
sdPath: dragData.sdPath,
|
||||
});
|
||||
|
||||
try {
|
||||
await addItem.mutateAsync({
|
||||
space_id: dropData.spaceId,
|
||||
group_id: dropData.groupId,
|
||||
item_type: { Path: { sd_path: dragData.sdPath } },
|
||||
});
|
||||
console.log("[DnD] Successfully added to group");
|
||||
} catch (err) {
|
||||
console.error("Failed to add item to group:", err);
|
||||
console.error("[DnD] Failed to add item to group:", err);
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -425,29 +618,44 @@ function DndWrapper({ children }: { children: React.ReactNode }) {
|
||||
>
|
||||
{children}
|
||||
<DragOverlay dropAnimation={null}>
|
||||
{activeItem?.file && activeItem.gridSize ? (
|
||||
<div style={{ width: activeItem.gridSize }}>
|
||||
<div className="flex flex-col items-center gap-2 p-1 rounded-lg relative">
|
||||
<div className="rounded-lg p-2">
|
||||
<FileComponent.Thumb
|
||||
file={activeItem.file}
|
||||
size={Math.max(
|
||||
activeItem.gridSize * 0.6,
|
||||
60,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm truncate px-2 py-0.5 rounded-md bg-accent text-white max-w-full">
|
||||
{activeItem.name}
|
||||
{activeItem?.file ? (
|
||||
activeItem.gridSize ? (
|
||||
// Grid view preview
|
||||
<div style={{ width: activeItem.gridSize }}>
|
||||
<div className="flex flex-col items-center gap-2 p-1 rounded-lg relative">
|
||||
<div className="rounded-lg p-2">
|
||||
<FileComponent.Thumb
|
||||
file={activeItem.file}
|
||||
size={Math.max(
|
||||
activeItem.gridSize * 0.6,
|
||||
60,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm truncate px-2 py-0.5 rounded-md bg-accent text-white max-w-full">
|
||||
{activeItem.name}
|
||||
</div>
|
||||
{/* Show count badge if dragging multiple files */}
|
||||
{activeItem.selectedFiles && activeItem.selectedFiles.length > 1 && (
|
||||
<div className="absolute -top-2 -right-2 size-6 rounded-full bg-accent text-white text-xs font-bold flex items-center justify-center shadow-lg border-2 border-app">
|
||||
{activeItem.selectedFiles.length}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Column/List view preview
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-md bg-accent text-white shadow-lg min-w-[200px] max-w-[300px]">
|
||||
<FileComponent.Thumb file={activeItem.file} size={24} />
|
||||
<span className="text-sm font-medium truncate">{activeItem.name}</span>
|
||||
{/* Show count badge if dragging multiple files */}
|
||||
{activeItem.selectedFiles && activeItem.selectedFiles.length > 1 && (
|
||||
<div className="absolute -top-2 -right-2 size-6 rounded-full bg-accent text-white text-xs font-bold flex items-center justify-center shadow-lg border-2 border-app">
|
||||
<div className="ml-auto size-5 rounded-full bg-white text-accent text-xs font-bold flex items-center justify-center">
|
||||
{activeItem.selectedFiles.length}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
|
||||
@@ -46,7 +46,7 @@ export function ColumnItem({
|
||||
<div ref={setNodeRef} {...listeners} {...attributes}>
|
||||
<FileComponent
|
||||
file={file}
|
||||
selected={selected}
|
||||
selected={selected && !isDragging}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onContextMenu={onContextMenu}
|
||||
@@ -54,11 +54,11 @@ export function ColumnItem({
|
||||
data-file-id={file.id}
|
||||
className={clsx(
|
||||
"flex items-center gap-2 px-3 py-1.5 mx-2 rounded-md cursor-default transition-none",
|
||||
selected
|
||||
selected && !isDragging
|
||||
? "bg-accent text-white"
|
||||
: "text-ink",
|
||||
focused && !selected && "ring-2 ring-accent/50",
|
||||
isDragging && "opacity-50"
|
||||
isDragging && "opacity-40"
|
||||
)}
|
||||
>
|
||||
<div className="[&_*]:!rounded-[3px] flex-shrink-0">
|
||||
|
||||
@@ -569,7 +569,7 @@ export const FileCard = memo(
|
||||
)}
|
||||
<FileComponent
|
||||
file={file}
|
||||
selected={selected}
|
||||
selected={selected && !dndIsDragging}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
@@ -577,14 +577,14 @@ export const FileCard = memo(
|
||||
className={clsx(
|
||||
"flex flex-col items-center gap-2 p-1 rounded-lg transition-all",
|
||||
focused && !selected && "ring-2 ring-accent/50",
|
||||
dndIsDragging && "opacity-50",
|
||||
dndIsDragging && "opacity-40",
|
||||
isFolder && isDropOver && "bg-accent/10",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
"rounded-lg p-2",
|
||||
selected ? "bg-app-box" : "bg-transparent",
|
||||
selected && !dndIsDragging ? "bg-app-box" : "bg-transparent",
|
||||
)}
|
||||
>
|
||||
<FileComponent.Thumb file={file} size={thumbSize} />
|
||||
@@ -593,7 +593,7 @@ export const FileCard = memo(
|
||||
<div
|
||||
className={clsx(
|
||||
"text-sm truncate px-2 py-0.5 rounded-md inline-block max-w-full",
|
||||
selected ? "bg-accent text-white" : "text-ink",
|
||||
selected && !dndIsDragging ? "bg-accent text-white" : "text-ink",
|
||||
)}
|
||||
>
|
||||
{file.name}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { CaretRight, Desktop, WifiHigh } from "@phosphor-icons/react";
|
||||
import clsx from "clsx";
|
||||
import { WifiHigh } from "@phosphor-icons/react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useLibraryQuery, getDeviceIcon } from "../../context";
|
||||
import { SpaceItem } from "./SpaceItem";
|
||||
import { GroupHeader } from "./GroupHeader";
|
||||
|
||||
interface DevicesGroupProps {
|
||||
isCollapsed: boolean;
|
||||
@@ -22,21 +23,7 @@ export function DevicesGroup({ isCollapsed, onToggle }: DevicesGroupProps) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="mb-1 flex w-full cursor-default items-center gap-2 px-1 text-xs font-semibold uppercase tracking-wider text-sidebar-ink-faint hover:text-sidebar-ink"
|
||||
>
|
||||
<CaretRight
|
||||
className={clsx("transition-transform", !isCollapsed && "rotate-90")}
|
||||
size={10}
|
||||
weight="bold"
|
||||
/>
|
||||
<span>Devices</span>
|
||||
{devices && devices.length > 0 && (
|
||||
<span className="ml-auto text-sidebar-ink-faint">{devices.length}</span>
|
||||
)}
|
||||
</button>
|
||||
<GroupHeader label="Devices" isCollapsed={isCollapsed} onToggle={onToggle} />
|
||||
|
||||
{/* Items */}
|
||||
{!isCollapsed && (
|
||||
@@ -46,47 +33,48 @@ export function DevicesGroup({ isCollapsed, onToggle }: DevicesGroupProps) {
|
||||
) : !devices || devices.length === 0 ? (
|
||||
<div className="px-2 py-1 text-xs text-sidebar-ink-faint">No devices</div>
|
||||
) : (
|
||||
devices.map((device) => (
|
||||
<button
|
||||
key={device.id}
|
||||
onClick={() => navigate(`/device/${device.id}`)}
|
||||
className={clsx(
|
||||
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium",
|
||||
device.is_current
|
||||
? "bg-sidebar-selected/30 text-sidebar-ink"
|
||||
: "text-sidebar-inkDull hover:text-sidebar-ink hover:bg-sidebar-box transition-colors"
|
||||
)}
|
||||
>
|
||||
{/* Device Icon */}
|
||||
<img src={getDeviceIcon(device)} alt="" className="size-4" />
|
||||
devices.map((device, index) => {
|
||||
// Create a minimal SpaceItem structure for the device
|
||||
const deviceItem = {
|
||||
id: device.id,
|
||||
item_type: "Overview" as const,
|
||||
};
|
||||
|
||||
{/* Device Name */}
|
||||
<span className="flex-1 truncate text-left">{device.name}</span>
|
||||
return (
|
||||
<SpaceItem
|
||||
key={device.id}
|
||||
item={deviceItem as any}
|
||||
customIcon={getDeviceIcon(device)}
|
||||
customLabel={device.name}
|
||||
allowInsertion={false}
|
||||
isLastItem={index === devices.length - 1}
|
||||
className="text-sidebar-inkDull"
|
||||
rightComponent={
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Paired indicator (network icon) */}
|
||||
{device.is_paired && (
|
||||
<WifiHigh
|
||||
size={12}
|
||||
weight="bold"
|
||||
className="text-accent"
|
||||
title="Paired via network"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Status Indicators */}
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Paired indicator (network icon) */}
|
||||
{device.is_paired && (
|
||||
<WifiHigh
|
||||
size={12}
|
||||
weight="bold"
|
||||
className="text-accent"
|
||||
title="Paired via network"
|
||||
/>
|
||||
)}
|
||||
{/* Offline indicator */}
|
||||
{!device.is_online && !device.is_connected && (
|
||||
<span className="text-xs text-ink-faint">Offline</span>
|
||||
)}
|
||||
|
||||
{/* Offline indicator */}
|
||||
{!device.is_online && !device.is_connected && (
|
||||
<span className="text-xs text-ink-faint">Offline</span>
|
||||
)}
|
||||
|
||||
{/* Connected indicator for paired devices */}
|
||||
{device.is_paired && device.is_connected && (
|
||||
<span className="text-xs text-accent">Connected</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
{/* Connected indicator for paired devices */}
|
||||
{device.is_paired && device.is_connected && (
|
||||
<span className="text-xs text-accent">Connected</span>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { CaretRight } from "@phosphor-icons/react";
|
||||
import clsx from "clsx";
|
||||
|
||||
interface GroupHeaderProps {
|
||||
label: string;
|
||||
isCollapsed: boolean;
|
||||
onToggle: () => void;
|
||||
rightComponent?: React.ReactNode;
|
||||
sortableAttributes?: any;
|
||||
sortableListeners?: any;
|
||||
}
|
||||
|
||||
export function GroupHeader({
|
||||
label,
|
||||
isCollapsed,
|
||||
onToggle,
|
||||
rightComponent,
|
||||
sortableAttributes,
|
||||
sortableListeners,
|
||||
}: GroupHeaderProps) {
|
||||
return (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
{...(sortableAttributes || {})}
|
||||
{...(sortableListeners || {})}
|
||||
className="mb-1 flex w-full cursor-default items-center gap-2 px-1 text-tiny font-semibold tracking-wider opacity-60 text-sidebar-ink-faint hover:text-sidebar-ink"
|
||||
>
|
||||
<CaretRight
|
||||
className={clsx("transition-transform", !isCollapsed && "rotate-90")}
|
||||
size={10}
|
||||
weight="bold"
|
||||
/>
|
||||
<span>{label}</span>
|
||||
{rightComponent}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { CaretRight, Folder } from "@phosphor-icons/react";
|
||||
import clsx from "clsx";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNormalizedQuery } from "@sd/ts-client";
|
||||
import { SpaceItem } from "./SpaceItem";
|
||||
import { GroupHeader } from "./GroupHeader";
|
||||
|
||||
interface LocationsGroupProps {
|
||||
isCollapsed: boolean;
|
||||
@@ -22,18 +21,7 @@ export function LocationsGroup({ isCollapsed, onToggle }: LocationsGroupProps) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="mb-1 flex w-full cursor-default items-center gap-2 px-1 text-xs font-semibold uppercase tracking-wider text-sidebar-ink-faint hover:text-sidebar-ink"
|
||||
>
|
||||
<CaretRight
|
||||
className={clsx("transition-transform", !isCollapsed && "rotate-90")}
|
||||
size={10}
|
||||
weight="bold"
|
||||
/>
|
||||
<span>Locations</span>
|
||||
</button>
|
||||
<GroupHeader label="Locations" isCollapsed={isCollapsed} onToggle={onToggle} />
|
||||
|
||||
{/* Items */}
|
||||
{!isCollapsed && (
|
||||
|
||||
@@ -1,95 +1,132 @@
|
||||
import { CaretRight } from '@phosphor-icons/react';
|
||||
import clsx from 'clsx';
|
||||
import type {
|
||||
SpaceGroup as SpaceGroupType,
|
||||
SpaceItem as SpaceItemType,
|
||||
} from '@sd/ts-client';
|
||||
import { useSidebarStore } from '@sd/ts-client';
|
||||
import { SpaceItem } from './SpaceItem';
|
||||
import { DeviceGroup } from './DeviceGroup';
|
||||
import { DevicesGroup } from './DevicesGroup';
|
||||
import { LocationsGroup } from './LocationsGroup';
|
||||
import { VolumesGroup } from './VolumesGroup';
|
||||
import { TagsGroup } from './TagsGroup';
|
||||
SpaceGroup as SpaceGroupType,
|
||||
SpaceItem as SpaceItemType,
|
||||
} from "@sd/ts-client";
|
||||
import { useSidebarStore } from "@sd/ts-client";
|
||||
import { SpaceItem } from "./SpaceItem";
|
||||
import { DeviceGroup } from "./DeviceGroup";
|
||||
import { DevicesGroup } from "./DevicesGroup";
|
||||
import { LocationsGroup } from "./LocationsGroup";
|
||||
import { VolumesGroup } from "./VolumesGroup";
|
||||
import { TagsGroup } from "./TagsGroup";
|
||||
import { GroupHeader } from "./GroupHeader";
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
|
||||
interface SpaceGroupProps {
|
||||
group: SpaceGroupType;
|
||||
items: SpaceItemType[];
|
||||
spaceId?: string;
|
||||
group: SpaceGroupType;
|
||||
items: SpaceItemType[];
|
||||
spaceId?: string;
|
||||
sortableAttributes?: any;
|
||||
sortableListeners?: any;
|
||||
}
|
||||
|
||||
export function SpaceGroup({ group, items, spaceId }: SpaceGroupProps) {
|
||||
const { collapsedGroups, toggleGroup } = useSidebarStore();
|
||||
// Use backend's is_collapsed value as the source of truth, fallback to local state
|
||||
const isCollapsed = group.is_collapsed ?? collapsedGroups.has(group.id);
|
||||
export function SpaceGroup({ group, items, spaceId, sortableAttributes, sortableListeners }: SpaceGroupProps) {
|
||||
const { collapsedGroups, toggleGroup } = useSidebarStore();
|
||||
// Use backend's is_collapsed value as the source of truth, fallback to local state
|
||||
const isCollapsed = group.is_collapsed ?? collapsedGroups.has(group.id);
|
||||
|
||||
// System groups (Locations, Volumes, etc.) are dynamic - don't allow insertion/reordering
|
||||
// Custom/QuickAccess groups allow insertion
|
||||
const allowInsertion = group.group_type === 'QuickAccess' || group.group_type === 'Custom';
|
||||
// System groups (Locations, Volumes, etc.) are dynamic - don't allow insertion/reordering
|
||||
// Custom/QuickAccess groups allow insertion
|
||||
const allowInsertion =
|
||||
group.group_type === "QuickAccess" || group.group_type === "Custom";
|
||||
|
||||
// Device groups are special - they show device info with children
|
||||
if (typeof group.group_type === 'object' && 'Device' in group.group_type) {
|
||||
return (
|
||||
<DeviceGroup
|
||||
deviceId={group.group_type.Device.device_id}
|
||||
items={items}
|
||||
isCollapsed={isCollapsed}
|
||||
onToggle={() => toggleGroup(group.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// Device groups are special - they show device info with children
|
||||
if (typeof group.group_type === "object" && "Device" in group.group_type) {
|
||||
return (
|
||||
<DeviceGroup
|
||||
deviceId={group.group_type.Device.device_id}
|
||||
items={items}
|
||||
isCollapsed={isCollapsed}
|
||||
onToggle={() => toggleGroup(group.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Devices group - fetches all devices (library + paired)
|
||||
if (group.group_type === 'Devices') {
|
||||
return <DevicesGroup isCollapsed={isCollapsed} onToggle={() => toggleGroup(group.id)} />;
|
||||
}
|
||||
// Devices group - fetches all devices (library + paired)
|
||||
if (group.group_type === "Devices") {
|
||||
return (
|
||||
<DevicesGroup
|
||||
isCollapsed={isCollapsed}
|
||||
onToggle={() => toggleGroup(group.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Locations group - fetches all locations
|
||||
if (group.group_type === 'Locations') {
|
||||
return <LocationsGroup isCollapsed={isCollapsed} onToggle={() => toggleGroup(group.id)} />;
|
||||
}
|
||||
// Locations group - fetches all locations
|
||||
if (group.group_type === "Locations") {
|
||||
return (
|
||||
<LocationsGroup
|
||||
isCollapsed={isCollapsed}
|
||||
onToggle={() => toggleGroup(group.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Volumes group - fetches all volumes
|
||||
if (group.group_type === 'Volumes') {
|
||||
return <VolumesGroup isCollapsed={isCollapsed} onToggle={() => toggleGroup(group.id)} />;
|
||||
}
|
||||
// Volumes group - fetches all volumes
|
||||
if (group.group_type === "Volumes") {
|
||||
return (
|
||||
<VolumesGroup
|
||||
isCollapsed={isCollapsed}
|
||||
onToggle={() => toggleGroup(group.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Tags group - fetches all tags
|
||||
if (group.group_type === 'Tags') {
|
||||
return <TagsGroup isCollapsed={isCollapsed} onToggle={() => toggleGroup(group.id)} />;
|
||||
}
|
||||
// Tags group - fetches all tags
|
||||
if (group.group_type === "Tags") {
|
||||
return (
|
||||
<TagsGroup
|
||||
isCollapsed={isCollapsed}
|
||||
onToggle={() => toggleGroup(group.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// QuickAccess and Custom groups render stored items
|
||||
return (
|
||||
<div className="rounded-lg">
|
||||
{/* Group Header */}
|
||||
<button
|
||||
onClick={() => toggleGroup(group.id)}
|
||||
className="mb-1 flex w-full items-center gap-2 px-1 text-xs font-semibold uppercase tracking-wider text-sidebar-ink-faint hover:text-sidebar-ink"
|
||||
>
|
||||
<CaretRight
|
||||
className={clsx('transition-transform', !isCollapsed && 'rotate-90')}
|
||||
size={10}
|
||||
weight="bold"
|
||||
/>
|
||||
<span>{group.name}</span>
|
||||
</button>
|
||||
// Empty drop zone for groups with no items
|
||||
const { setNodeRef: setEmptyRef, isOver: isOverEmpty } = useDroppable({
|
||||
id: `group-${group.id}-empty`,
|
||||
disabled: !allowInsertion || isCollapsed,
|
||||
data: {
|
||||
action: "add-to-group",
|
||||
groupId: group.id,
|
||||
spaceId,
|
||||
},
|
||||
});
|
||||
|
||||
{/* Items */}
|
||||
{!isCollapsed && (
|
||||
<div className="space-y-0.5">
|
||||
{items.map((item, index) => (
|
||||
<SpaceItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
isLastItem={index === items.length - 1}
|
||||
allowInsertion={allowInsertion}
|
||||
spaceId={spaceId}
|
||||
groupId={group.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
// QuickAccess and Custom groups render stored items
|
||||
return (
|
||||
<div className="rounded-lg">
|
||||
<GroupHeader
|
||||
label={group.name}
|
||||
isCollapsed={isCollapsed}
|
||||
onToggle={() => toggleGroup(group.id)}
|
||||
sortableAttributes={sortableAttributes}
|
||||
sortableListeners={sortableListeners}
|
||||
/>
|
||||
|
||||
{/* Items */}
|
||||
{!isCollapsed && (
|
||||
<div className="space-y-0.5 relative min-h-[20px]">
|
||||
{items.length > 0 ? (
|
||||
items.map((item, index) => (
|
||||
<SpaceItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
isLastItem={index === items.length - 1}
|
||||
allowInsertion={allowInsertion}
|
||||
spaceId={spaceId}
|
||||
groupId={group.id}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<div ref={setEmptyRef} className="absolute inset-0 z-10">
|
||||
{isOverEmpty && (
|
||||
<div className="absolute top-1/2 -translate-y-1/2 left-2 right-2 h-[2px] bg-accent rounded-full" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ import { useContextMenu } from "../../hooks/useContextMenu";
|
||||
import { usePlatform } from "../../platform";
|
||||
import { useLibraryMutation } from "../../context";
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
import { useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
interface SpaceItemProps {
|
||||
item: SpaceItemType;
|
||||
@@ -38,6 +40,8 @@ interface SpaceItemProps {
|
||||
volumeData?: { device_slug: string; mount_path: string };
|
||||
/** Optional custom icon (as image path) to override default icon */
|
||||
customIcon?: string;
|
||||
/** Optional custom label to override automatic label detection */
|
||||
customLabel?: string;
|
||||
/** Whether this is the last item in the list (for showing bottom insertion line) */
|
||||
isLastItem?: boolean;
|
||||
/** Whether this item supports insertion (reordering) - false for system groups */
|
||||
@@ -46,6 +50,8 @@ interface SpaceItemProps {
|
||||
spaceId?: string;
|
||||
/** The group ID this item belongs to (for adding items on insertion) */
|
||||
groupId?: string | null;
|
||||
/** Whether this item is sortable (can be reordered) */
|
||||
sortable?: boolean;
|
||||
}
|
||||
|
||||
function getItemIcon(itemType: ItemType): any {
|
||||
@@ -96,8 +102,12 @@ function getItemPath(
|
||||
if (itemType === "Overview") return "/";
|
||||
if (itemType === "Recents") return "/recents";
|
||||
if (itemType === "Favorites") return "/favorites";
|
||||
if (typeof itemType === "object" && "Location" in itemType)
|
||||
if (typeof itemType === "object" && "Location" in itemType) {
|
||||
// For proper SpaceItem with Location type, we need the sd_path
|
||||
// This requires the parent to pass volumeData or similar
|
||||
// For now, keep the old route - will be replaced when locations use raw format
|
||||
return `/location/${itemType.Location.location_id}`;
|
||||
}
|
||||
if (typeof itemType === "object" && "Volume" in itemType) {
|
||||
// Navigate to explorer with volume's root path
|
||||
if (volumeData) {
|
||||
@@ -114,12 +124,9 @@ function getItemPath(
|
||||
if (typeof itemType === "object" && "Tag" in itemType)
|
||||
return `/tag/${itemType.Tag.tag_id}`;
|
||||
if (typeof itemType === "object" && "Path" in itemType) {
|
||||
// If it's a directory, navigate to explorer
|
||||
if (resolvedFile?.kind === "Directory") {
|
||||
return `/explorer?path=${encodeURIComponent(JSON.stringify(itemType.Path.sd_path))}`;
|
||||
}
|
||||
// Regular files don't have a path to navigate to (could open/preview in future)
|
||||
return null;
|
||||
// Navigate to explorer with the SD path
|
||||
// Assume it's explorable (directory or file) - if it's in the sidebar, it should be clickable
|
||||
return `/explorer?path=${encodeURIComponent(JSON.stringify(itemType.Path.sd_path))}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -132,16 +139,38 @@ export function SpaceItem({
|
||||
onClick,
|
||||
volumeData,
|
||||
customIcon,
|
||||
customLabel,
|
||||
isLastItem = false,
|
||||
allowInsertion = true,
|
||||
spaceId,
|
||||
groupId,
|
||||
sortable = false,
|
||||
}: SpaceItemProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const platform = usePlatform();
|
||||
const deleteItem = useLibraryMutation("spaces.delete_item");
|
||||
|
||||
// Sortable hook (for reordering)
|
||||
const sortableProps = useSortable({
|
||||
id: item.id,
|
||||
disabled: !sortable,
|
||||
});
|
||||
|
||||
const {
|
||||
attributes: sortableAttributes,
|
||||
listeners: sortableListeners,
|
||||
setNodeRef: setSortableRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging: isSortableDragging,
|
||||
} = sortableProps;
|
||||
|
||||
const style = sortable ? {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
} : undefined;
|
||||
|
||||
// Check if this is a raw location object (has 'name' and 'sd_path' but no 'item_type')
|
||||
const isRawLocation =
|
||||
"name" in item && "sd_path" in item && !item.item_type;
|
||||
@@ -155,7 +184,9 @@ export function SpaceItem({
|
||||
// Handle raw location object
|
||||
iconData = { type: "image", icon: Location };
|
||||
label = (item as any).name || "Unnamed Location";
|
||||
path = `/location/${item.id}`;
|
||||
// Use explorer path with the location's sd_path
|
||||
const sdPath = (item as any).sd_path;
|
||||
path = sdPath ? `/explorer?path=${encodeURIComponent(JSON.stringify(sdPath))}` : null;
|
||||
} else {
|
||||
// Handle proper SpaceItem
|
||||
iconData = getItemIcon(item.item_type);
|
||||
@@ -169,13 +200,38 @@ export function SpaceItem({
|
||||
iconData = { type: "image", icon: customIcon };
|
||||
}
|
||||
|
||||
// Check if this item is active
|
||||
// For paths with query params (like volumes), compare full path including search
|
||||
const isActive = path
|
||||
? path.includes("?")
|
||||
? location.pathname + location.search === path
|
||||
: location.pathname === path
|
||||
: false;
|
||||
// Override with custom label if provided
|
||||
if (customLabel) {
|
||||
label = customLabel;
|
||||
}
|
||||
|
||||
// Check if this item is active by comparing SD paths
|
||||
const isActive = (() => {
|
||||
if (!path) return false;
|
||||
|
||||
// For explorer paths with query params, compare the SD path parameter
|
||||
if (path.startsWith("/explorer?path=")) {
|
||||
const currentSearchParams = new URLSearchParams(location.search);
|
||||
const currentPathParam = currentSearchParams.get("path");
|
||||
const itemPathParam = new URLSearchParams(path.split("?")[1]).get("path");
|
||||
|
||||
// Compare the actual SD path objects, not just the encoded strings
|
||||
if (currentPathParam && itemPathParam) {
|
||||
try {
|
||||
const currentSdPath = JSON.parse(decodeURIComponent(currentPathParam));
|
||||
const itemSdPath = JSON.parse(decodeURIComponent(itemPathParam));
|
||||
return JSON.stringify(currentSdPath) === JSON.stringify(itemSdPath);
|
||||
} catch {
|
||||
// If parsing fails, fall back to string comparison
|
||||
return currentPathParam === itemPathParam;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// For non-explorer routes, use simple path matching
|
||||
return location.pathname === path;
|
||||
})();
|
||||
|
||||
const handleClick = () => {
|
||||
if (onClick) {
|
||||
@@ -229,12 +285,10 @@ export function SpaceItem({
|
||||
icon: Trash,
|
||||
label: "Remove from Space",
|
||||
onClick: async () => {
|
||||
if (confirm(`Remove "${label}" from this space?`)) {
|
||||
try {
|
||||
await deleteItem.mutateAsync({ item_id: item.id });
|
||||
} catch (err) {
|
||||
console.error("Failed to remove item:", err);
|
||||
}
|
||||
try {
|
||||
await deleteItem.mutateAsync({ item_id: item.id });
|
||||
} catch (err) {
|
||||
console.error("Failed to remove item:", err);
|
||||
}
|
||||
},
|
||||
variant: "danger" as const,
|
||||
@@ -321,14 +375,18 @@ export function SpaceItem({
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={setSortableRef}
|
||||
style={style}
|
||||
className={clsx("relative", isSortableDragging && "opacity-50 z-50")}
|
||||
>
|
||||
{/* Insertion line indicator - only show top (bottom of previous item handles gaps) */}
|
||||
{isOverTop && (
|
||||
{isOverTop && !isSortableDragging && (
|
||||
<div className="absolute -top-[1px] left-2 right-2 h-[2px] bg-accent z-20 rounded-full" />
|
||||
)}
|
||||
|
||||
{/* Ring highlight for drop-into */}
|
||||
{isOverMiddle && isDropTarget && (
|
||||
{isOverMiddle && isDropTarget && !isSortableDragging && (
|
||||
<div className="absolute inset-0 rounded-md ring-2 ring-accent/50 ring-inset pointer-events-none z-10" />
|
||||
)}
|
||||
|
||||
@@ -375,6 +433,7 @@ export function SpaceItem({
|
||||
<button
|
||||
onClick={handleClick}
|
||||
onContextMenu={handleContextMenu}
|
||||
{...(sortable ? { ...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",
|
||||
className ||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { CaretRight, Tag as TagIcon, Plus } from '@phosphor-icons/react';
|
||||
import { Tag as TagIcon, Plus } from '@phosphor-icons/react';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import clsx from 'clsx';
|
||||
import { useNormalizedQuery, useLibraryMutation } from '../../context';
|
||||
import type { Tag } from '@sd/ts-client';
|
||||
import { GroupHeader } from './GroupHeader';
|
||||
|
||||
interface TagsGroupProps {
|
||||
isCollapsed: boolean;
|
||||
@@ -119,21 +120,16 @@ export function TagsGroup({ isCollapsed, onToggle }: TagsGroupProps) {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header */}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="mb-1 flex w-full items-center gap-2 px-1 text-xs font-semibold uppercase tracking-wider text-sidebar-ink-faint hover:text-sidebar-ink"
|
||||
>
|
||||
<CaretRight
|
||||
className={clsx('transition-transform', !isCollapsed && 'rotate-90')}
|
||||
size={10}
|
||||
weight="bold"
|
||||
/>
|
||||
<span>Tags</span>
|
||||
{tags.length > 0 && (
|
||||
<span className="ml-auto text-sidebar-ink-faint">{tags.length}</span>
|
||||
)}
|
||||
</button>
|
||||
<GroupHeader
|
||||
label="Tags"
|
||||
isCollapsed={isCollapsed}
|
||||
onToggle={onToggle}
|
||||
rightComponent={
|
||||
tags.length > 0 && (
|
||||
<span className="ml-auto text-sidebar-ink-faint">{tags.length}</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Items */}
|
||||
{!isCollapsed && (
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { CaretRight } from "@phosphor-icons/react";
|
||||
import clsx from "clsx";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useNormalizedQuery, getVolumeIcon } from "@sd/ts-client";
|
||||
import { SpaceItem } from "./SpaceItem";
|
||||
import { GroupHeader } from "./GroupHeader";
|
||||
import type { VolumeItem } from "@sd/ts-client";
|
||||
|
||||
interface VolumesGroupProps {
|
||||
@@ -41,18 +40,7 @@ export function VolumesGroup({
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Group Header */}
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="mb-1 flex w-full cursor-default items-center gap-2 px-1 text-xs font-semibold uppercase tracking-wider text-sidebar-ink-faint hover:text-sidebar-ink"
|
||||
>
|
||||
<CaretRight
|
||||
className={clsx("transition-transform", !isCollapsed && "rotate-90")}
|
||||
size={10}
|
||||
weight="bold"
|
||||
/>
|
||||
<span>Volumes</span>
|
||||
</button>
|
||||
<GroupHeader label="Volumes" isCollapsed={isCollapsed} onToggle={onToggle} />
|
||||
|
||||
{/* Volumes List */}
|
||||
{!isCollapsed && (
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { useNormalizedQuery } from '@sd/ts-client';
|
||||
import { useSpacedriveClient } from '../../../context';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect } from 'react';
|
||||
import type { Event } from '@sd/ts-client';
|
||||
|
||||
export function useSpaces() {
|
||||
return useNormalizedQuery({
|
||||
@@ -9,11 +13,75 @@ export function useSpaces() {
|
||||
}
|
||||
|
||||
export function useSpaceLayout(spaceId: string | null) {
|
||||
return useNormalizedQuery({
|
||||
const client = useSpacedriveClient();
|
||||
const queryClient = useQueryClient();
|
||||
const libraryId = client.getCurrentLibraryId();
|
||||
|
||||
const query = useNormalizedQuery({
|
||||
wireMethod: 'query:spaces.get_layout',
|
||||
input: spaceId ? { space_id: spaceId } : null,
|
||||
resourceType: 'space_layout',
|
||||
resourceId: spaceId || undefined,
|
||||
enabled: !!spaceId,
|
||||
});
|
||||
|
||||
// Subscribe to space_item deletions to update the layout
|
||||
// (space_item sends its own ResourceDeleted events, separate from space_layout)
|
||||
useEffect(() => {
|
||||
if (!spaceId || !libraryId) return;
|
||||
|
||||
const handleEvent = (event: Event) => {
|
||||
if (typeof event === 'string') return;
|
||||
|
||||
if ('ResourceDeleted' in event) {
|
||||
const { resource_type, resource_id } = (event as any).ResourceDeleted;
|
||||
|
||||
if (resource_type === 'space_item') {
|
||||
console.log('[useSpaceLayout] Space item deleted, updating layout:', resource_id);
|
||||
|
||||
// Remove the item from the layout cache
|
||||
const queryKey = ['query:spaces.get_layout', libraryId, { space_id: spaceId }];
|
||||
queryClient.setQueryData(queryKey, (oldData: any) => {
|
||||
if (!oldData) return oldData;
|
||||
|
||||
// Remove from space_items array
|
||||
const updatedSpaceItems = oldData.space_items?.filter(
|
||||
(item: any) => item.id !== resource_id
|
||||
) || [];
|
||||
|
||||
// Remove from groups
|
||||
const updatedGroups = oldData.groups?.map((group: any) => ({
|
||||
...group,
|
||||
items: group.items.filter((item: any) => item.id !== resource_id),
|
||||
})) || [];
|
||||
|
||||
return {
|
||||
...oldData,
|
||||
space_items: updatedSpaceItems,
|
||||
groups: updatedGroups,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
|
||||
client.subscribeFiltered(
|
||||
{
|
||||
resource_type: 'space_item',
|
||||
library_id: libraryId,
|
||||
include_descendants: false,
|
||||
},
|
||||
handleEvent
|
||||
).then((unsub) => {
|
||||
unsubscribe = unsub;
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [client, queryClient, spaceId, libraryId]);
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { GearSix } from "@phosphor-icons/react";
|
||||
import { useSidebarStore, useLibraryMutation } from "@sd/ts-client";
|
||||
import type { SpaceGroup as SpaceGroupType, SpaceItem as SpaceItemType } from "@sd/ts-client";
|
||||
import { useSpaces, useSpaceLayout } from "./hooks/useSpaces";
|
||||
import { SpaceSwitcher } from "./SpaceSwitcher";
|
||||
import { SpaceGroup } from "./SpaceGroup";
|
||||
@@ -13,6 +14,66 @@ import { JobManagerPopover } from "../JobManager/JobManagerPopover";
|
||||
import { SyncMonitorPopover } from "../SyncMonitor";
|
||||
import clsx from "clsx";
|
||||
import { useDroppable } from "@dnd-kit/core";
|
||||
import { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
|
||||
// Wrapper that adds a space-level drop zone before each group and makes it sortable
|
||||
function SpaceGroupWithDropZone({
|
||||
group,
|
||||
items,
|
||||
spaceId,
|
||||
isFirst,
|
||||
}: {
|
||||
group: SpaceGroupType;
|
||||
items: SpaceItemType[];
|
||||
spaceId?: string;
|
||||
isFirst: boolean;
|
||||
}) {
|
||||
const { setNodeRef: setDropRef, isOver } = useDroppable({
|
||||
id: `space-root-before-${group.id}`,
|
||||
disabled: !spaceId,
|
||||
data: {
|
||||
action: 'add-to-space',
|
||||
spaceId,
|
||||
groupId: null,
|
||||
},
|
||||
});
|
||||
|
||||
// Sortable for group reordering
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef: setSortableRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: group.id,
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setSortableRef} style={style} className={clsx("relative", isDragging && "opacity-50 z-50")}>
|
||||
{/* Drop zone before this group (for adding root-level items) */}
|
||||
<div ref={setDropRef} className="absolute -top-2.5 left-0 right-0 h-5 z-10">
|
||||
{isOver && !isDragging && (
|
||||
<div className="absolute top-1/2 -translate-y-1/2 left-2 right-2 h-[2px] bg-accent rounded-full" />
|
||||
)}
|
||||
</div>
|
||||
<SpaceGroup
|
||||
group={group}
|
||||
items={items}
|
||||
spaceId={spaceId}
|
||||
sortableAttributes={attributes}
|
||||
sortableListeners={listeners}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SpacesSidebarProps {
|
||||
isPreviewActive?: boolean;
|
||||
@@ -93,24 +154,43 @@ export function SpacesSidebar({ isPreviewActive = false }: SpacesSidebarProps) {
|
||||
<div className="no-scrollbar mt-3 mask-fade-out flex grow flex-col space-y-5 overflow-x-hidden overflow-y-scroll pb-10">
|
||||
{/* Space-level items (pinned shortcuts) */}
|
||||
{layout?.space_items && layout.space_items.length > 0 && (
|
||||
<div className="space-y-0.5">
|
||||
{layout.space_items.map((item, index) => (
|
||||
<SpaceItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
isLastItem={index === layout.space_items.length - 1}
|
||||
allowInsertion={true}
|
||||
spaceId={currentSpace?.id}
|
||||
groupId={null}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<SortableContext
|
||||
items={layout.space_items.map(item => item.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-0.5">
|
||||
{layout.space_items.map((item, index) => (
|
||||
<SpaceItem
|
||||
key={item.id}
|
||||
item={item}
|
||||
isLastItem={index === layout.space_items.length - 1}
|
||||
allowInsertion={true}
|
||||
spaceId={currentSpace?.id}
|
||||
groupId={null}
|
||||
sortable={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
)}
|
||||
|
||||
{/* Groups */}
|
||||
{layout?.groups.map(({ group, items }) => (
|
||||
<SpaceGroup key={group.id} group={group} items={items} spaceId={currentSpace?.id} />
|
||||
))}
|
||||
{/* Groups with space-level drop zones between them */}
|
||||
{layout?.groups && (
|
||||
<SortableContext
|
||||
items={layout.groups.map(({ group }) => group.id)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
{layout.groups.map(({ group, items }, index) => (
|
||||
<SpaceGroupWithDropZone
|
||||
key={group.id}
|
||||
group={group}
|
||||
items={items}
|
||||
spaceId={currentSpace?.id}
|
||||
isFirst={index === 0}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
)}
|
||||
|
||||
{/* Add Group Button */}
|
||||
{currentSpace && <AddGroupButton spaceId={currentSpace.id} />}
|
||||
|
||||
@@ -450,6 +450,89 @@ export type EntryKind =
|
||||
*/
|
||||
"Symlink";
|
||||
|
||||
/**
|
||||
* Status of the unified ephemeral index cache
|
||||
*/
|
||||
export type EphemeralCacheStatus = {
|
||||
/**
|
||||
* Number of paths that have been indexed
|
||||
*/
|
||||
indexed_paths_count: number;
|
||||
/**
|
||||
* Number of paths currently being indexed
|
||||
*/
|
||||
indexing_in_progress_count: number;
|
||||
/**
|
||||
* Unified index statistics (shared arena and string interning)
|
||||
*/
|
||||
index_stats: UnifiedIndexStats;
|
||||
/**
|
||||
* List of indexed paths (directories whose contents are ready)
|
||||
*/
|
||||
indexed_paths: IndexedPathInfo[];
|
||||
/**
|
||||
* List of paths currently being indexed
|
||||
*/
|
||||
paths_in_progress: string[]; total_indexes?: number | null; indexing_in_progress?: number | null; indexes?: EphemeralIndexInfo[] };
|
||||
|
||||
/**
|
||||
* Input for the ephemeral cache status query
|
||||
*/
|
||||
export type EphemeralCacheStatusInput = {
|
||||
/**
|
||||
* Optional: only include indexed paths containing this substring
|
||||
*/
|
||||
path_filter?: string | null };
|
||||
|
||||
/**
|
||||
* Legacy: Information about a single ephemeral index (for backward compatibility)
|
||||
*/
|
||||
export type EphemeralIndexInfo = {
|
||||
/**
|
||||
* Root path this index covers
|
||||
*/
|
||||
root_path: string;
|
||||
/**
|
||||
* Whether indexing is currently in progress
|
||||
*/
|
||||
indexing_in_progress: boolean;
|
||||
/**
|
||||
* Total entries in the arena
|
||||
*/
|
||||
total_entries: number;
|
||||
/**
|
||||
* Number of entries indexed by path
|
||||
*/
|
||||
path_index_count: number;
|
||||
/**
|
||||
* Number of unique interned names
|
||||
*/
|
||||
unique_names: number;
|
||||
/**
|
||||
* Number of interned strings in cache
|
||||
*/
|
||||
interned_strings: number;
|
||||
/**
|
||||
* Number of content kinds stored
|
||||
*/
|
||||
content_kinds: number;
|
||||
/**
|
||||
* Estimated memory usage in bytes
|
||||
*/
|
||||
memory_bytes: number;
|
||||
/**
|
||||
* Age of the index in seconds
|
||||
*/
|
||||
age_seconds: number;
|
||||
/**
|
||||
* Seconds since last access
|
||||
*/
|
||||
idle_seconds: number;
|
||||
/**
|
||||
* Indexer job statistics (files/dirs/bytes counted)
|
||||
*/
|
||||
job_stats: JobStats };
|
||||
|
||||
/**
|
||||
* Error event for tracking recent errors
|
||||
*/
|
||||
@@ -662,7 +745,7 @@ export type FileByPathQuery = { path: string };
|
||||
/**
|
||||
* Internal enum for file conflict resolution strategies
|
||||
*/
|
||||
export type FileConflictResolution = "Overwrite" | "AutoModifyName" | "Abort";
|
||||
export type FileConflictResolution = "Overwrite" | "AutoModifyName" | "Skip" | "Abort";
|
||||
|
||||
/**
|
||||
* Core input structure for file copy operations
|
||||
@@ -1079,7 +1162,12 @@ include_hidden: boolean;
|
||||
persistence: IndexPersistence };
|
||||
|
||||
/**
|
||||
* Indexing mode determines the depth of indexing
|
||||
* How deeply to index files, from metadata-only to full processing.
|
||||
*
|
||||
* IndexMode controls the trade-off between indexing speed and feature completeness.
|
||||
* Shallow mode is fast enough for ephemeral browsing, while Deep mode enables
|
||||
* duplicate detection, thumbnail generation, and full-text search at the cost of
|
||||
* significantly longer indexing time.
|
||||
*/
|
||||
export type IndexMode =
|
||||
/**
|
||||
@@ -1087,20 +1175,27 @@ export type IndexMode =
|
||||
*/
|
||||
"None" |
|
||||
/**
|
||||
* Just filesystem metadata (fastest)
|
||||
* Just filesystem metadata
|
||||
*/
|
||||
"Shallow" |
|
||||
/**
|
||||
* Generate content identities (moderate)
|
||||
* Generate content identities via sampled BLAKE3 hashing (enables duplicate detection)
|
||||
*/
|
||||
"Content" |
|
||||
/**
|
||||
* Full indexing with thumbnails and text extraction (slowest)
|
||||
* Full indexing with thumbnails and text extraction
|
||||
*/
|
||||
"Deep";
|
||||
|
||||
/**
|
||||
* Determines whether indexing results are persisted to database or kept in memory
|
||||
* Whether to write indexing results to the database or keep them in memory.
|
||||
*
|
||||
* Ephemeral persistence allows users to browse external drives and network shares
|
||||
* without adding them as managed locations. The in-memory index survives for the
|
||||
* session duration and provides the same API surface as persistent entries, enabling
|
||||
* features like search and navigation to work identically for both modes. If an
|
||||
* ephemeral path is later promoted to a managed location, UUIDs are preserved to
|
||||
* maintain continuity for user metadata.
|
||||
*/
|
||||
export type IndexPersistence =
|
||||
/**
|
||||
@@ -1113,7 +1208,12 @@ export type IndexPersistence =
|
||||
"Ephemeral";
|
||||
|
||||
/**
|
||||
* Indexing scope determines how much of the directory tree to process
|
||||
* Whether to index just one directory level or recurse through subdirectories.
|
||||
*
|
||||
* Current scope is used for UI navigation where users expand folders on-demand,
|
||||
* while Recursive scope is used for full location indexing. Current scope with
|
||||
* persistent storage enables progressive indexing where the UI drives which
|
||||
* directories get indexed based on user interaction.
|
||||
*/
|
||||
export type IndexScope =
|
||||
/**
|
||||
@@ -1165,7 +1265,20 @@ path: string;
|
||||
duration_secs: number };
|
||||
|
||||
/**
|
||||
* Comprehensive metrics for indexing operations
|
||||
* Information about an indexed path
|
||||
*/
|
||||
export type IndexedPathInfo = {
|
||||
/**
|
||||
* The directory path that was indexed
|
||||
*/
|
||||
path: string;
|
||||
/**
|
||||
* Number of direct children in this directory
|
||||
*/
|
||||
child_count: number };
|
||||
|
||||
/**
|
||||
* Complete snapshot of indexer performance after job completion.
|
||||
*/
|
||||
export type IndexerMetrics = { total_duration: { secs: number; nanos: number }; discovery_duration: { secs: number; nanos: number }; processing_duration: { secs: number; nanos: number }; content_duration: { secs: number; nanos: number }; files_per_second: number; bytes_per_second: number; dirs_per_second: number; db_writes: number; db_reads: number; batch_count: number; avg_batch_size: number; total_errors: number; critical_errors: number; non_critical_errors: number; skipped_paths: number; peak_memory_bytes: number | null; avg_memory_bytes: number | null };
|
||||
|
||||
@@ -1175,7 +1288,7 @@ export type IndexerMetrics = { total_duration: { secs: number; nanos: number };
|
||||
export type IndexerSettings = { no_system_files?: boolean; no_git?: boolean; no_dev_dirs?: boolean; no_hidden?: boolean; gitignore?: boolean; only_images?: boolean };
|
||||
|
||||
/**
|
||||
* Statistics collected during indexing
|
||||
* Cumulative statistics tracked throughout the indexing process.
|
||||
*/
|
||||
export type IndexerStats = { files: number; dirs: number; bytes: number; symlinks: number; skipped: number; errors: number };
|
||||
|
||||
@@ -1398,6 +1511,27 @@ export type JobResumeInput = { job_id: string };
|
||||
|
||||
export type JobResumeOutput = { job_id: string; success: boolean };
|
||||
|
||||
/**
|
||||
* Statistics from the indexer job
|
||||
*/
|
||||
export type JobStats = {
|
||||
/**
|
||||
* Number of files indexed
|
||||
*/
|
||||
files: number;
|
||||
/**
|
||||
* Number of directories indexed
|
||||
*/
|
||||
dirs: number;
|
||||
/**
|
||||
* Number of symlinks indexed
|
||||
*/
|
||||
symlinks: number;
|
||||
/**
|
||||
* Total bytes indexed
|
||||
*/
|
||||
bytes: number };
|
||||
|
||||
/**
|
||||
* Current status of a job
|
||||
*/
|
||||
@@ -2433,7 +2567,7 @@ statistics: LibraryStatistics };
|
||||
|
||||
export type ReorderGroupsInput = { space_id: string; group_ids: string[] };
|
||||
|
||||
export type ReorderItemsInput = { group_id: string; item_ids: string[] };
|
||||
export type ReorderItemsInput = { group_id: string | null; item_ids: string[] };
|
||||
|
||||
export type ReorderOutput = { success: boolean };
|
||||
|
||||
@@ -3150,6 +3284,43 @@ export type TranscribeAudioOutput = {
|
||||
*/
|
||||
job_id: string };
|
||||
|
||||
/**
|
||||
* Statistics for the unified ephemeral index
|
||||
*/
|
||||
export type UnifiedIndexStats = {
|
||||
/**
|
||||
* Total entries in the shared arena
|
||||
*/
|
||||
total_entries: number;
|
||||
/**
|
||||
* Number of entries indexed by path
|
||||
*/
|
||||
path_index_count: number;
|
||||
/**
|
||||
* Number of unique interned names (shared across all paths)
|
||||
*/
|
||||
unique_names: number;
|
||||
/**
|
||||
* Number of interned strings in shared cache
|
||||
*/
|
||||
interned_strings: number;
|
||||
/**
|
||||
* Number of content kinds stored
|
||||
*/
|
||||
content_kinds: number;
|
||||
/**
|
||||
* Estimated memory usage in bytes
|
||||
*/
|
||||
memory_bytes: number;
|
||||
/**
|
||||
* Age of the cache in seconds
|
||||
*/
|
||||
age_seconds: number;
|
||||
/**
|
||||
* Seconds since last access
|
||||
*/
|
||||
idle_seconds: number };
|
||||
|
||||
/**
|
||||
* Input for finding files unique to a location
|
||||
*/
|
||||
@@ -3520,197 +3691,199 @@ success: boolean };
|
||||
// ===== API Type Unions =====
|
||||
|
||||
export type CoreAction =
|
||||
{ type: 'network.stop'; input: NetworkStopInput; output: NetworkStopOutput }
|
||||
| { type: 'network.device.revoke'; input: DeviceRevokeInput; output: DeviceRevokeOutput }
|
||||
| { type: 'network.pair.join'; input: PairJoinInput; output: PairJoinOutput }
|
||||
{ type: 'network.pair.generate'; input: PairGenerateInput; output: PairGenerateOutput }
|
||||
| { type: 'network.sync_setup'; input: LibrarySyncSetupInput; output: LibrarySyncSetupOutput }
|
||||
| { type: 'network.pair.generate'; input: PairGenerateInput; output: PairGenerateOutput }
|
||||
| { type: 'network.device.revoke'; input: DeviceRevokeInput; output: DeviceRevokeOutput }
|
||||
| { type: 'models.whisper.delete'; input: DeleteWhisperModelInput; output: DeleteWhisperModelOutput }
|
||||
| { type: 'models.whisper.download'; input: DownloadWhisperModelInput; output: DownloadWhisperModelOutput }
|
||||
| { type: 'network.spacedrop.send'; input: SpacedropSendInput; output: SpacedropSendOutput }
|
||||
| { type: 'libraries.create'; input: LibraryCreateInput; output: LibraryCreateOutput }
|
||||
| { type: 'network.stop'; input: NetworkStopInput; output: NetworkStopOutput }
|
||||
| { type: 'libraries.open'; input: LibraryOpenInput; output: LibraryOpenOutput }
|
||||
| { type: 'network.spacedrop.send'; input: SpacedropSendInput; output: SpacedropSendOutput }
|
||||
| { type: 'network.pair.cancel'; input: PairCancelInput; output: PairCancelOutput }
|
||||
| { type: 'libraries.delete'; input: LibraryDeleteInput; output: LibraryDeleteOutput }
|
||||
| { type: 'network.start'; input: NetworkStartInput; output: NetworkStartOutput }
|
||||
| { type: 'libraries.create'; input: LibraryCreateInput; output: LibraryCreateOutput }
|
||||
| { type: 'network.pair.join'; input: PairJoinInput; output: PairJoinOutput }
|
||||
;
|
||||
|
||||
export type LibraryAction =
|
||||
{ type: 'spaces.create'; input: SpaceCreateInput; output: SpaceCreateOutput }
|
||||
{ type: 'locations.export'; input: LocationExportInput; output: LocationExportOutput }
|
||||
| { type: 'tags.apply'; input: ApplyTagsInput; output: ApplyTagsOutput }
|
||||
| { type: 'indexing.verify'; input: IndexVerifyInput; output: IndexVerifyOutput }
|
||||
| { type: 'media.ocr.extract'; input: ExtractTextInput; output: ExtractTextOutput }
|
||||
| { type: 'volumes.add_cloud'; input: VolumeAddCloudInput; output: VolumeAddCloudOutput }
|
||||
| { type: 'jobs.pause'; input: JobPauseInput; output: JobPauseOutput }
|
||||
| { type: 'media.proxy.generate'; input: GenerateProxyInput; output: GenerateProxyOutput }
|
||||
| { type: 'libraries.rename'; input: LibraryRenameInput; output: LibraryRenameOutput }
|
||||
| { type: 'spaces.update'; input: SpaceUpdateInput; output: SpaceUpdateOutput }
|
||||
| { type: 'tags.create'; input: CreateTagInput; output: CreateTagOutput }
|
||||
| { type: 'spaces.delete_item'; input: DeleteItemInput; output: DeleteItemOutput }
|
||||
| { type: 'volumes.remove_cloud'; input: VolumeRemoveCloudInput; output: VolumeRemoveCloudOutput }
|
||||
| { type: 'locations.triggerJob'; input: LocationTriggerJobInput; output: LocationTriggerJobOutput }
|
||||
| { type: 'locations.enable_indexing'; input: EnableIndexingInput; output: EnableIndexingOutput }
|
||||
| { type: 'spaces.reorder_items'; input: ReorderItemsInput; output: ReorderOutput }
|
||||
| { type: 'spaces.reorder_groups'; input: ReorderGroupsInput; output: ReorderOutput }
|
||||
| { type: 'spaces.add_item'; input: AddItemInput; output: AddItemOutput }
|
||||
| { type: 'volumes.track'; input: VolumeTrackInput; output: VolumeTrackOutput }
|
||||
| { type: 'libraries.export'; input: LibraryExportInput; output: LibraryExportOutput }
|
||||
| { type: 'locations.remove'; input: LocationRemoveInput; output: LocationRemoveOutput }
|
||||
| { type: 'jobs.resume'; input: JobResumeInput; output: JobResumeOutput }
|
||||
| { type: 'jobs.cancel'; input: JobCancelInput; output: JobCancelOutput }
|
||||
| { type: 'media.speech.transcribe'; input: TranscribeAudioInput; output: TranscribeAudioOutput }
|
||||
| { type: 'spaces.update_group'; input: UpdateGroupInput; output: UpdateGroupOutput }
|
||||
| { type: 'spaces.add_group'; input: AddGroupInput; output: AddGroupOutput }
|
||||
| { type: 'volumes.add_cloud'; input: VolumeAddCloudInput; output: VolumeAddCloudOutput }
|
||||
| { type: 'files.copy'; input: FileCopyInput; output: JobReceipt }
|
||||
| { type: 'volumes.refresh'; input: VolumeRefreshInput; output: VolumeRefreshOutput }
|
||||
| { type: 'locations.import'; input: LocationImportInput; output: LocationImportOutput }
|
||||
| { type: 'files.delete'; input: FileDeleteInput; output: JobReceipt }
|
||||
| { type: 'spaces.create'; input: SpaceCreateInput; output: SpaceCreateOutput }
|
||||
| { type: 'spaces.delete_group'; input: DeleteGroupInput; output: DeleteGroupOutput }
|
||||
| { type: 'media.thumbnail.regenerate'; input: RegenerateThumbnailInput; output: RegenerateThumbnailOutput }
|
||||
| { type: 'media.thumbnail'; input: ThumbnailInput; output: JobReceipt }
|
||||
| { type: 'indexing.verify'; input: IndexVerifyInput; output: IndexVerifyOutput }
|
||||
| { type: 'libraries.rename'; input: LibraryRenameInput; output: LibraryRenameOutput }
|
||||
| { type: 'volumes.track'; input: VolumeTrackInput; output: VolumeTrackOutput }
|
||||
| { type: 'volumes.remove_cloud'; input: VolumeRemoveCloudInput; output: VolumeRemoveCloudOutput }
|
||||
| { type: 'files.copy'; input: FileCopyInput; output: JobReceipt }
|
||||
| { type: 'jobs.pause'; input: JobPauseInput; output: JobPauseOutput }
|
||||
| { type: 'spaces.delete_item'; input: DeleteItemInput; output: DeleteItemOutput }
|
||||
| { type: 'volumes.speed_test'; input: VolumeSpeedTestInput; output: VolumeSpeedTestOutput }
|
||||
| { type: 'jobs.resume'; input: JobResumeInput; output: JobResumeOutput }
|
||||
| { type: 'locations.update'; input: LocationUpdateInput; output: LocationUpdateOutput }
|
||||
| { type: 'spaces.delete'; input: SpaceDeleteInput; output: SpaceDeleteOutput }
|
||||
| { type: 'locations.triggerJob'; input: LocationTriggerJobInput; output: LocationTriggerJobOutput }
|
||||
| { type: 'locations.export'; input: LocationExportInput; output: LocationExportOutput }
|
||||
| { type: 'tags.create'; input: CreateTagInput; output: CreateTagOutput }
|
||||
| { type: 'locations.enable_indexing'; input: EnableIndexingInput; output: EnableIndexingOutput }
|
||||
| { type: 'media.proxy.generate'; input: GenerateProxyInput; output: GenerateProxyOutput }
|
||||
| { type: 'volumes.refresh'; input: VolumeRefreshInput; output: VolumeRefreshOutput }
|
||||
| { type: 'spaces.update_group'; input: UpdateGroupInput; output: UpdateGroupOutput }
|
||||
| { type: 'libraries.export'; input: LibraryExportInput; output: LibraryExportOutput }
|
||||
| { type: 'volumes.untrack'; input: VolumeUntrackInput; output: VolumeUntrackOutput }
|
||||
| { type: 'volumes.speed_test'; input: VolumeSpeedTestInput; output: VolumeSpeedTestOutput }
|
||||
| { type: 'locations.rescan'; input: LocationRescanInput; output: LocationRescanOutput }
|
||||
| { type: 'locations.add'; input: LocationAddInput; output: LocationAddOutput }
|
||||
| { type: 'indexing.start'; input: IndexInput; output: JobReceipt }
|
||||
| { type: 'locations.remove'; input: LocationRemoveInput; output: LocationRemoveOutput }
|
||||
| { type: 'files.delete'; input: FileDeleteInput; output: JobReceipt }
|
||||
| { type: 'tags.apply'; input: ApplyTagsInput; output: ApplyTagsOutput }
|
||||
| { type: 'spaces.add_item'; input: AddItemInput; output: AddItemOutput }
|
||||
| { type: 'media.ocr.extract'; input: ExtractTextInput; output: ExtractTextOutput }
|
||||
| { type: 'media.thumbstrip.generate'; input: GenerateThumbstripInput; output: GenerateThumbstripOutput }
|
||||
| { type: 'spaces.update'; input: SpaceUpdateInput; output: SpaceUpdateOutput }
|
||||
| { type: 'volumes.untrack'; input: VolumeUntrackInput; output: VolumeUntrackOutput }
|
||||
| { type: 'locations.import'; input: LocationImportInput; output: LocationImportOutput }
|
||||
| { type: 'jobs.cancel'; input: JobCancelInput; output: JobCancelOutput }
|
||||
| { type: 'spaces.delete_group'; input: DeleteGroupInput; output: DeleteGroupOutput }
|
||||
| { type: 'media.speech.transcribe'; input: TranscribeAudioInput; output: TranscribeAudioOutput }
|
||||
| { type: 'locations.rescan'; input: LocationRescanInput; output: LocationRescanOutput }
|
||||
| { type: 'locations.update'; input: LocationUpdateInput; output: LocationUpdateOutput }
|
||||
;
|
||||
|
||||
export type CoreQuery =
|
||||
{ type: 'network.pair.status'; input: PairStatusQueryInput; output: PairStatusOutput }
|
||||
| { type: 'network.sync_setup.discover'; input: DiscoverRemoteLibrariesInput; output: DiscoverRemoteLibrariesOutput }
|
||||
{ type: 'core.ephemeral_status'; input: EphemeralCacheStatusInput; output: EphemeralCacheStatus }
|
||||
| { type: 'core.events.list'; input: ListEventsInput; output: ListEventsOutput }
|
||||
| { type: 'network.status'; input: NetworkStatusQueryInput; output: NetworkStatus }
|
||||
| { type: 'libraries.list'; input: ListLibrariesInput; output: [LibraryInfo] }
|
||||
| { type: 'network.devices.list'; input: ListPairedDevicesInput; output: ListPairedDevicesOutput }
|
||||
| { type: 'models.whisper.list'; input: ListWhisperModelsInput; output: ListWhisperModelsOutput }
|
||||
| { type: 'network.sync_setup.discover'; input: DiscoverRemoteLibrariesInput; output: DiscoverRemoteLibrariesOutput }
|
||||
| { type: 'core.status'; input: Empty; output: CoreStatus }
|
||||
| { type: 'libraries.list'; input: ListLibrariesInput; output: [LibraryInfo] }
|
||||
| { type: 'network.pair.status'; input: PairStatusQueryInput; output: PairStatusOutput }
|
||||
;
|
||||
|
||||
export type LibraryQuery =
|
||||
{ type: 'files.by_id'; input: FileByIdQuery; output: File }
|
||||
| { type: 'spaces.get'; input: SpaceGetQueryInput; output: SpaceGetOutput }
|
||||
| { type: 'libraries.info'; input: LibraryInfoQueryInput; output: LibraryInfoOutput }
|
||||
| { type: 'jobs.active'; input: ActiveJobsInput; output: ActiveJobsOutput }
|
||||
| { type: 'locations.list'; input: LocationsListQueryInput; output: LocationsListOutput }
|
||||
| { type: 'sync.activity'; input: GetSyncActivityInput; output: GetSyncActivityOutput }
|
||||
| { type: 'tags.search'; input: SearchTagsInput; output: SearchTagsOutput }
|
||||
| { type: 'jobs.info'; input: JobInfoQueryInput; output: JobInfoOutput }
|
||||
| { type: 'locations.suggested'; input: SuggestedLocationsQueryInput; output: SuggestedLocationsOutput }
|
||||
| { type: 'test.ping'; input: PingInput; output: PingOutput }
|
||||
| { type: 'jobs.list'; input: JobListInput; output: JobListOutput }
|
||||
| { type: 'spaces.list'; input: SpacesListQueryInput; output: SpacesListOutput }
|
||||
| { type: 'search.files'; input: FileSearchInput; output: FileSearchOutput }
|
||||
| { type: 'devices.list'; input: ListLibraryDevicesInput; output: [LibraryDeviceInfo] }
|
||||
| { type: 'files.media_listing'; input: MediaListingInput; output: MediaListingOutput }
|
||||
| { type: 'files.directory_listing'; input: DirectoryListingInput; output: DirectoryListingOutput }
|
||||
| { type: 'sync.metrics'; input: GetSyncMetricsInput; output: GetSyncMetricsOutput }
|
||||
| { type: 'spaces.get_layout'; input: SpaceLayoutQueryInput; output: SpaceLayout }
|
||||
| { type: 'volumes.list'; input: VolumeListQueryInput; output: VolumeListOutput }
|
||||
| { type: 'files.by_path'; input: FileByPathQuery; output: File }
|
||||
{ type: 'test.ping'; input: PingInput; output: PingOutput }
|
||||
| { type: 'files.unique_to_location'; input: UniqueToLocationInput; output: UniqueToLocationOutput }
|
||||
| { type: 'jobs.active'; input: ActiveJobsInput; output: ActiveJobsOutput }
|
||||
| { type: 'jobs.list'; input: JobListInput; output: JobListOutput }
|
||||
| { type: 'spaces.get_layout'; input: SpaceLayoutQueryInput; output: SpaceLayout }
|
||||
| { type: 'spaces.list'; input: SpacesListQueryInput; output: SpacesListOutput }
|
||||
| { type: 'sync.activity'; input: GetSyncActivityInput; output: GetSyncActivityOutput }
|
||||
| { type: 'jobs.info'; input: JobInfoQueryInput; output: JobInfoOutput }
|
||||
| { type: 'files.directory_listing'; input: DirectoryListingInput; output: DirectoryListingOutput }
|
||||
| { type: 'files.by_path'; input: FileByPathQuery; output: File }
|
||||
| { type: 'files.media_listing'; input: MediaListingInput; output: MediaListingOutput }
|
||||
| { type: 'locations.suggested'; input: SuggestedLocationsQueryInput; output: SuggestedLocationsOutput }
|
||||
| { type: 'spaces.get'; input: SpaceGetQueryInput; output: SpaceGetOutput }
|
||||
| { type: 'sync.eventLog'; input: GetSyncEventLogInput; output: GetSyncEventLogOutput }
|
||||
| { type: 'files.by_id'; input: FileByIdQuery; output: File }
|
||||
| { type: 'sync.metrics'; input: GetSyncMetricsInput; output: GetSyncMetricsOutput }
|
||||
| { type: 'volumes.list'; input: VolumeListQueryInput; output: VolumeListOutput }
|
||||
| { type: 'search.files'; input: FileSearchInput; output: FileSearchOutput }
|
||||
| { type: 'libraries.info'; input: LibraryInfoQueryInput; output: LibraryInfoOutput }
|
||||
| { type: 'locations.list'; input: LocationsListQueryInput; output: LocationsListOutput }
|
||||
| { type: 'tags.search'; input: SearchTagsInput; output: SearchTagsOutput }
|
||||
| { type: 'devices.list'; input: ListLibraryDevicesInput; output: [LibraryDeviceInfo] }
|
||||
;
|
||||
|
||||
// ===== Wire Method Mappings =====
|
||||
|
||||
export const WIRE_METHODS = {
|
||||
coreActions: {
|
||||
'network.stop': 'action:network.stop.input',
|
||||
'network.device.revoke': 'action:network.device.revoke.input',
|
||||
'network.pair.join': 'action:network.pair.join.input',
|
||||
'network.sync_setup': 'action:network.sync_setup.input',
|
||||
'network.pair.generate': 'action:network.pair.generate.input',
|
||||
'network.sync_setup': 'action:network.sync_setup.input',
|
||||
'network.device.revoke': 'action:network.device.revoke.input',
|
||||
'models.whisper.delete': 'action:models.whisper.delete.input',
|
||||
'models.whisper.download': 'action:models.whisper.download.input',
|
||||
'network.spacedrop.send': 'action:network.spacedrop.send.input',
|
||||
'libraries.create': 'action:libraries.create.input',
|
||||
'network.stop': 'action:network.stop.input',
|
||||
'libraries.open': 'action:libraries.open.input',
|
||||
'network.spacedrop.send': 'action:network.spacedrop.send.input',
|
||||
'network.pair.cancel': 'action:network.pair.cancel.input',
|
||||
'libraries.delete': 'action:libraries.delete.input',
|
||||
'network.start': 'action:network.start.input',
|
||||
'libraries.create': 'action:libraries.create.input',
|
||||
'network.pair.join': 'action:network.pair.join.input',
|
||||
},
|
||||
|
||||
libraryActions: {
|
||||
'spaces.create': 'action:spaces.create.input',
|
||||
'locations.export': 'action:locations.export.input',
|
||||
'tags.apply': 'action:tags.apply.input',
|
||||
'indexing.verify': 'action:indexing.verify.input',
|
||||
'media.ocr.extract': 'action:media.ocr.extract.input',
|
||||
'volumes.add_cloud': 'action:volumes.add_cloud.input',
|
||||
'jobs.pause': 'action:jobs.pause.input',
|
||||
'media.proxy.generate': 'action:media.proxy.generate.input',
|
||||
'libraries.rename': 'action:libraries.rename.input',
|
||||
'spaces.update': 'action:spaces.update.input',
|
||||
'tags.create': 'action:tags.create.input',
|
||||
'spaces.delete_item': 'action:spaces.delete_item.input',
|
||||
'volumes.remove_cloud': 'action:volumes.remove_cloud.input',
|
||||
'locations.triggerJob': 'action:locations.triggerJob.input',
|
||||
'locations.enable_indexing': 'action:locations.enable_indexing.input',
|
||||
'spaces.reorder_items': 'action:spaces.reorder_items.input',
|
||||
'spaces.reorder_groups': 'action:spaces.reorder_groups.input',
|
||||
'spaces.add_item': 'action:spaces.add_item.input',
|
||||
'volumes.track': 'action:volumes.track.input',
|
||||
'libraries.export': 'action:libraries.export.input',
|
||||
'locations.remove': 'action:locations.remove.input',
|
||||
'jobs.resume': 'action:jobs.resume.input',
|
||||
'jobs.cancel': 'action:jobs.cancel.input',
|
||||
'media.speech.transcribe': 'action:media.speech.transcribe.input',
|
||||
'spaces.update_group': 'action:spaces.update_group.input',
|
||||
'spaces.add_group': 'action:spaces.add_group.input',
|
||||
'volumes.add_cloud': 'action:volumes.add_cloud.input',
|
||||
'files.copy': 'action:files.copy.input',
|
||||
'volumes.refresh': 'action:volumes.refresh.input',
|
||||
'locations.import': 'action:locations.import.input',
|
||||
'files.delete': 'action:files.delete.input',
|
||||
'spaces.create': 'action:spaces.create.input',
|
||||
'spaces.delete_group': 'action:spaces.delete_group.input',
|
||||
'media.thumbnail.regenerate': 'action:media.thumbnail.regenerate.input',
|
||||
'media.thumbnail': 'action:media.thumbnail.input',
|
||||
'indexing.verify': 'action:indexing.verify.input',
|
||||
'libraries.rename': 'action:libraries.rename.input',
|
||||
'volumes.track': 'action:volumes.track.input',
|
||||
'volumes.remove_cloud': 'action:volumes.remove_cloud.input',
|
||||
'files.copy': 'action:files.copy.input',
|
||||
'jobs.pause': 'action:jobs.pause.input',
|
||||
'spaces.delete_item': 'action:spaces.delete_item.input',
|
||||
'volumes.speed_test': 'action:volumes.speed_test.input',
|
||||
'jobs.resume': 'action:jobs.resume.input',
|
||||
'locations.update': 'action:locations.update.input',
|
||||
'spaces.delete': 'action:spaces.delete.input',
|
||||
'locations.triggerJob': 'action:locations.triggerJob.input',
|
||||
'locations.export': 'action:locations.export.input',
|
||||
'tags.create': 'action:tags.create.input',
|
||||
'locations.enable_indexing': 'action:locations.enable_indexing.input',
|
||||
'media.proxy.generate': 'action:media.proxy.generate.input',
|
||||
'volumes.refresh': 'action:volumes.refresh.input',
|
||||
'spaces.update_group': 'action:spaces.update_group.input',
|
||||
'libraries.export': 'action:libraries.export.input',
|
||||
'volumes.untrack': 'action:volumes.untrack.input',
|
||||
'volumes.speed_test': 'action:volumes.speed_test.input',
|
||||
'locations.rescan': 'action:locations.rescan.input',
|
||||
'locations.add': 'action:locations.add.input',
|
||||
'indexing.start': 'action:indexing.start.input',
|
||||
'locations.remove': 'action:locations.remove.input',
|
||||
'files.delete': 'action:files.delete.input',
|
||||
'tags.apply': 'action:tags.apply.input',
|
||||
'spaces.add_item': 'action:spaces.add_item.input',
|
||||
'media.ocr.extract': 'action:media.ocr.extract.input',
|
||||
'media.thumbstrip.generate': 'action:media.thumbstrip.generate.input',
|
||||
'spaces.update': 'action:spaces.update.input',
|
||||
'volumes.untrack': 'action:volumes.untrack.input',
|
||||
'locations.import': 'action:locations.import.input',
|
||||
'jobs.cancel': 'action:jobs.cancel.input',
|
||||
'spaces.delete_group': 'action:spaces.delete_group.input',
|
||||
'media.speech.transcribe': 'action:media.speech.transcribe.input',
|
||||
'locations.rescan': 'action:locations.rescan.input',
|
||||
'locations.update': 'action:locations.update.input',
|
||||
},
|
||||
|
||||
coreQueries: {
|
||||
'network.pair.status': 'query:network.pair.status',
|
||||
'network.sync_setup.discover': 'query:network.sync_setup.discover',
|
||||
'core.ephemeral_status': 'query:core.ephemeral_status',
|
||||
'core.events.list': 'query:core.events.list',
|
||||
'network.status': 'query:network.status',
|
||||
'libraries.list': 'query:libraries.list',
|
||||
'network.devices.list': 'query:network.devices.list',
|
||||
'models.whisper.list': 'query:models.whisper.list',
|
||||
'network.sync_setup.discover': 'query:network.sync_setup.discover',
|
||||
'core.status': 'query:core.status',
|
||||
'libraries.list': 'query:libraries.list',
|
||||
'network.pair.status': 'query:network.pair.status',
|
||||
},
|
||||
|
||||
libraryQueries: {
|
||||
'files.by_id': 'query:files.by_id',
|
||||
'spaces.get': 'query:spaces.get',
|
||||
'libraries.info': 'query:libraries.info',
|
||||
'jobs.active': 'query:jobs.active',
|
||||
'locations.list': 'query:locations.list',
|
||||
'sync.activity': 'query:sync.activity',
|
||||
'tags.search': 'query:tags.search',
|
||||
'jobs.info': 'query:jobs.info',
|
||||
'locations.suggested': 'query:locations.suggested',
|
||||
'test.ping': 'query:test.ping',
|
||||
'jobs.list': 'query:jobs.list',
|
||||
'spaces.list': 'query:spaces.list',
|
||||
'search.files': 'query:search.files',
|
||||
'devices.list': 'query:devices.list',
|
||||
'files.media_listing': 'query:files.media_listing',
|
||||
'files.directory_listing': 'query:files.directory_listing',
|
||||
'sync.metrics': 'query:sync.metrics',
|
||||
'spaces.get_layout': 'query:spaces.get_layout',
|
||||
'volumes.list': 'query:volumes.list',
|
||||
'files.by_path': 'query:files.by_path',
|
||||
'files.unique_to_location': 'query:files.unique_to_location',
|
||||
'jobs.active': 'query:jobs.active',
|
||||
'jobs.list': 'query:jobs.list',
|
||||
'spaces.get_layout': 'query:spaces.get_layout',
|
||||
'spaces.list': 'query:spaces.list',
|
||||
'sync.activity': 'query:sync.activity',
|
||||
'jobs.info': 'query:jobs.info',
|
||||
'files.directory_listing': 'query:files.directory_listing',
|
||||
'files.by_path': 'query:files.by_path',
|
||||
'files.media_listing': 'query:files.media_listing',
|
||||
'locations.suggested': 'query:locations.suggested',
|
||||
'spaces.get': 'query:spaces.get',
|
||||
'sync.eventLog': 'query:sync.eventLog',
|
||||
'files.by_id': 'query:files.by_id',
|
||||
'sync.metrics': 'query:sync.metrics',
|
||||
'volumes.list': 'query:volumes.list',
|
||||
'search.files': 'query:search.files',
|
||||
'libraries.info': 'query:libraries.info',
|
||||
'locations.list': 'query:locations.list',
|
||||
'tags.search': 'query:tags.search',
|
||||
'devices.list': 'query:devices.list',
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -142,7 +142,7 @@ export function useNormalizedQuery<I, O>(
|
||||
// This ensures subscription re-runs when path changes, even if object reference stays same
|
||||
const pathScopeSerialized = useMemo(
|
||||
() => JSON.stringify(options.pathScope),
|
||||
[options.pathScope]
|
||||
[options.pathScope],
|
||||
);
|
||||
|
||||
// Event subscription
|
||||
@@ -153,16 +153,14 @@ export function useNormalizedQuery<I, O>(
|
||||
|
||||
// Skip subscription for file queries without pathScope (prevent overly broad subscriptions)
|
||||
// Unless resourceId is provided (single-file queries like FileInspector don't need pathScope)
|
||||
if (options.resourceType === "file" && !options.pathScope && !options.resourceId) {
|
||||
if (
|
||||
options.resourceType === "file" &&
|
||||
!options.pathScope &&
|
||||
!options.resourceId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[useNormalizedQuery] Creating subscription', {
|
||||
resourceType: options.resourceType,
|
||||
pathScope: options.pathScope,
|
||||
includeDescendants: options.includeDescendants ?? false,
|
||||
});
|
||||
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
let isCancelled = false;
|
||||
|
||||
@@ -172,20 +170,25 @@ export function useNormalizedQuery<I, O>(
|
||||
|
||||
const handleEvent = (event: Event) => {
|
||||
// Debug: log every batch event to understand what's happening
|
||||
if (typeof event !== 'string' && 'ResourceChangedBatch' in event) {
|
||||
const batch = (event as any).ResourceChangedBatch;
|
||||
console.log('[useNormalizedQuery] Batch event received', {
|
||||
capturedPath: capturedPathScope,
|
||||
currentRefPath: optionsRef.current.pathScope,
|
||||
pathsMatch: JSON.stringify(optionsRef.current.pathScope) === JSON.stringify(capturedPathScope),
|
||||
resourceCount: batch.resources?.length || 0,
|
||||
resourceType: batch.resource_type,
|
||||
});
|
||||
}
|
||||
// if (typeof event !== "string" && "ResourceChangedBatch" in event) {
|
||||
// const batch = (event as any).ResourceChangedBatch;
|
||||
// console.log("[useNormalizedQuery] Batch event received", {
|
||||
// capturedPath: capturedPathScope,
|
||||
// currentRefPath: optionsRef.current.pathScope,
|
||||
// pathsMatch:
|
||||
// JSON.stringify(optionsRef.current.pathScope) ===
|
||||
// JSON.stringify(capturedPathScope),
|
||||
// resourceCount: batch.resources?.length || 0,
|
||||
// resourceType: batch.resource_type,
|
||||
// });
|
||||
// }
|
||||
|
||||
// Guard: only process events if pathScope hasn't changed since subscription
|
||||
if (JSON.stringify(optionsRef.current.pathScope) !== JSON.stringify(capturedPathScope)) {
|
||||
console.log('[useNormalizedQuery] Dropping stale event', {
|
||||
if (
|
||||
JSON.stringify(optionsRef.current.pathScope) !==
|
||||
JSON.stringify(capturedPathScope)
|
||||
) {
|
||||
console.log("[useNormalizedQuery] Dropping stale event", {
|
||||
eventPathScope: capturedPathScope,
|
||||
currentPathScope: optionsRef.current.pathScope,
|
||||
});
|
||||
@@ -212,20 +215,22 @@ export function useNormalizedQuery<I, O>(
|
||||
)
|
||||
.then((unsub) => {
|
||||
if (isCancelled) {
|
||||
console.log('[useNormalizedQuery] Subscription cancelled before creation completed');
|
||||
// console.log(
|
||||
// "[useNormalizedQuery] Subscription cancelled before creation completed",
|
||||
// );
|
||||
unsub();
|
||||
} else {
|
||||
console.log('[useNormalizedQuery] Subscription active', {
|
||||
pathScope: options.pathScope,
|
||||
});
|
||||
// console.log("[useNormalizedQuery] Subscription active", {
|
||||
// pathScope: options.pathScope,
|
||||
// });
|
||||
unsubscribe = unsub;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
console.log('[useNormalizedQuery] Cleaning up subscription', {
|
||||
pathScope: options.pathScope,
|
||||
});
|
||||
// console.log("[useNormalizedQuery] Cleaning up subscription", {
|
||||
// pathScope: options.pathScope,
|
||||
// });
|
||||
isCancelled = true;
|
||||
unsubscribe?.();
|
||||
};
|
||||
@@ -272,10 +277,10 @@ export function handleResourceEvent(
|
||||
if ("ResourceChanged" in event) {
|
||||
const result = v.safeParse(ResourceChangedSchema, event);
|
||||
if (!result.success) {
|
||||
console.warn(
|
||||
"[useNormalizedQuery] Invalid ResourceChanged event:",
|
||||
result.issues,
|
||||
);
|
||||
// console.warn(
|
||||
// "[useNormalizedQuery] Invalid ResourceChanged event:",
|
||||
// result.issues,
|
||||
// );
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -289,10 +294,10 @@ export function handleResourceEvent(
|
||||
else if ("ResourceChangedBatch" in event) {
|
||||
const result = v.safeParse(ResourceChangedBatchSchema, event);
|
||||
if (!result.success) {
|
||||
console.warn(
|
||||
"[useNormalizedQuery] Invalid ResourceChangedBatch event:",
|
||||
result.issues,
|
||||
);
|
||||
// console.warn(
|
||||
// "[useNormalizedQuery] Invalid ResourceChangedBatch event:",
|
||||
// result.issues,
|
||||
// );
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -308,10 +313,10 @@ export function handleResourceEvent(
|
||||
else if ("ResourceDeleted" in event) {
|
||||
const result = v.safeParse(ResourceDeletedSchema, event);
|
||||
if (!result.success) {
|
||||
console.warn(
|
||||
"[useNormalizedQuery] Invalid ResourceDeleted event:",
|
||||
result.issues,
|
||||
);
|
||||
// console.warn(
|
||||
// "[useNormalizedQuery] Invalid ResourceDeleted event:",
|
||||
// result.issues,
|
||||
// );
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -381,7 +386,8 @@ export function filterBatchResources(
|
||||
const physicalFromAlternate = alternatePaths.find((p: any) => p.Physical);
|
||||
const physicalFromSdPath = resource.sd_path?.Physical;
|
||||
|
||||
const physicalPath = physicalFromAlternate?.Physical || physicalFromSdPath;
|
||||
const physicalPath =
|
||||
physicalFromAlternate?.Physical || physicalFromSdPath;
|
||||
|
||||
if (!physicalPath?.path) {
|
||||
return false; // No physical path found
|
||||
@@ -401,15 +407,15 @@ export function filterBatchResources(
|
||||
return parentDir === normalizedScope;
|
||||
});
|
||||
|
||||
const afterCount = filtered.length;
|
||||
if (beforeCount !== afterCount) {
|
||||
console.log('[filterBatchResources] Filtered resources', {
|
||||
pathScope: options.pathScope,
|
||||
before: beforeCount,
|
||||
after: afterCount,
|
||||
filtered: beforeCount - afterCount,
|
||||
});
|
||||
}
|
||||
// const afterCount = filtered.length;
|
||||
// if (beforeCount !== afterCount) {
|
||||
// console.log("[filterBatchResources] Filtered resources", {
|
||||
// pathScope: options.pathScope,
|
||||
// before: beforeCount,
|
||||
// after: afterCount,
|
||||
// filtered: beforeCount - afterCount,
|
||||
// });
|
||||
// }
|
||||
}
|
||||
|
||||
return filtered;
|
||||
@@ -436,10 +442,10 @@ export function updateSingleResource<O>(
|
||||
if (options) {
|
||||
resourcesToUpdate = filterBatchResources(resourcesToUpdate, options);
|
||||
if (resourcesToUpdate.length === 0) {
|
||||
console.log('[updateSingleResource] Filtered out resource', {
|
||||
pathScope: options.pathScope,
|
||||
resourcePath: resource.sd_path,
|
||||
});
|
||||
// console.log("[updateSingleResource] Filtered out resource", {
|
||||
// pathScope: options.pathScope,
|
||||
// resourcePath: resource.sd_path,
|
||||
// });
|
||||
return; // Resource was filtered out
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,189 +1,195 @@
|
||||
const defaultTheme = require('tailwindcss/defaultTheme');
|
||||
const defaultTheme = require("tailwindcss/defaultTheme");
|
||||
|
||||
function alpha(variableName) {
|
||||
// some tailwind magic to allow us to specify opacity with CSS variables (eg: bg-app/80)
|
||||
// https://tailwindcss.com/docs/customizing-colors#using-css-variables
|
||||
return `hsla(var(${variableName}), <alpha-value>)`;
|
||||
// some tailwind magic to allow us to specify opacity with CSS variables (eg: bg-app/80)
|
||||
// https://tailwindcss.com/docs/customizing-colors#using-css-variables
|
||||
return `hsla(var(${variableName}), <alpha-value>)`;
|
||||
}
|
||||
|
||||
module.exports = function (app, options) {
|
||||
/**
|
||||
* @type {import('tailwindcss').Config}
|
||||
*/
|
||||
let config = {
|
||||
content: [
|
||||
`../../apps/${app}/src/**/*.{ts,tsx,html,stories.tsx}`,
|
||||
'../../packages/*/src/**/*.{ts,tsx,html,stories.tsx}',
|
||||
'../../interface/**/*.{ts,tsx,html,stories.tsx}'
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
screens: {
|
||||
xs: '475px',
|
||||
sm: '650px',
|
||||
md: '868px',
|
||||
lg: '1024px',
|
||||
xl: '1280px'
|
||||
},
|
||||
fontFamily: {
|
||||
sans: [...defaultTheme.fontFamily.sans],
|
||||
plex: ['IBM Plex Sans', ...defaultTheme.fontFamily.sans]
|
||||
},
|
||||
fontSize: {
|
||||
'tiny': '.65rem',
|
||||
'xs': '.75rem',
|
||||
'sm': '.80rem',
|
||||
'base': '1rem',
|
||||
'lg': '1.125rem',
|
||||
'xl': '1.25rem',
|
||||
'2xl': '1.5rem',
|
||||
'3xl': '1.875rem',
|
||||
'4xl': '2.25rem',
|
||||
'5xl': '3rem',
|
||||
'6xl': '4rem',
|
||||
'7xl': '5rem'
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
accent: {
|
||||
DEFAULT: alpha('--color-accent'),
|
||||
faint: 'hsl(var(--color-accent-faint))',
|
||||
deep: alpha('--color-accent-deep')
|
||||
},
|
||||
ink: {
|
||||
DEFAULT: alpha('--color-ink'),
|
||||
dull: alpha('--color-ink-dull'),
|
||||
faint: alpha('--color-ink-faint')
|
||||
},
|
||||
sidebar: {
|
||||
DEFAULT: alpha('--color-sidebar'),
|
||||
box: alpha('--color-sidebar-box'),
|
||||
line: alpha('--color-sidebar-line'),
|
||||
ink: alpha('--color-sidebar-ink'),
|
||||
inkFaint: alpha('--color-sidebar-ink-faint'),
|
||||
inkDull: alpha('--color-sidebar-ink-dull'),
|
||||
divider: alpha('--color-sidebar-divider'),
|
||||
button: alpha('--color-sidebar-button'),
|
||||
selected: alpha('--color-sidebar-selected'),
|
||||
shade: alpha('--color-sidebar-shade')
|
||||
},
|
||||
app: {
|
||||
DEFAULT: alpha('--color-app'),
|
||||
box: alpha('--color-app-box'),
|
||||
darkBox: alpha('--color-app-dark-box'),
|
||||
darkerBox: alpha('--color-app-darker-box'),
|
||||
lightBox: alpha('--color-app-light-box'),
|
||||
overlay: alpha('--color-app-overlay'),
|
||||
input: alpha('--color-app-input'),
|
||||
focus: alpha('--color-app-focus'),
|
||||
line: alpha('--color-app-line'),
|
||||
divider: alpha('--color-app-divider'),
|
||||
button: alpha('--color-app-button'),
|
||||
selected: alpha('--color-app-selected'),
|
||||
selectedItem: alpha('--color-app-selected-item'),
|
||||
hover: alpha('--color-app-hover'),
|
||||
active: alpha('--color-app-active'),
|
||||
shade: alpha('--color-app-shade'),
|
||||
frame: alpha('--color-app-frame'),
|
||||
slider: alpha('--color-app-slider'),
|
||||
explorerScrollbar: alpha('--color-app-explorer-scrollbar')
|
||||
},
|
||||
menu: {
|
||||
DEFAULT: alpha('--color-menu'),
|
||||
line: alpha('--color-menu-line'),
|
||||
hover: alpha('--color-menu-hover'),
|
||||
selected: alpha('--color-menu-selected'),
|
||||
shade: alpha('--color-menu-shade'),
|
||||
ink: alpha('--color-menu-ink'),
|
||||
faint: alpha('--color-menu-faint')
|
||||
},
|
||||
// legacy support
|
||||
primary: {
|
||||
DEFAULT: '#2599FF',
|
||||
50: '#FFFFFF',
|
||||
100: '#F1F8FF',
|
||||
200: '#BEE1FF',
|
||||
300: '#8BC9FF',
|
||||
400: '#58B1FF',
|
||||
500: '#2599FF',
|
||||
600: '#0081F1',
|
||||
700: '#0065BE',
|
||||
800: '#004A8B',
|
||||
900: '#002F58'
|
||||
},
|
||||
gray: {
|
||||
DEFAULT: '#505468',
|
||||
50: '#F1F1F4',
|
||||
100: '#E8E9ED',
|
||||
150: '#E0E1E6',
|
||||
200: '#D8DAE3',
|
||||
250: '#D2D4DC',
|
||||
300: '#C0C2CE',
|
||||
350: '#A6AABF',
|
||||
400: '#9196A8',
|
||||
450: '#71758A',
|
||||
500: '#303544',
|
||||
550: '#20222d',
|
||||
600: '#171720',
|
||||
650: '#121219',
|
||||
700: '#121317',
|
||||
750: '#0D0E11',
|
||||
800: '#0C0C0F',
|
||||
850: '#08090D',
|
||||
900: '#060609',
|
||||
950: '#030303'
|
||||
}
|
||||
},
|
||||
extend: {
|
||||
transitionTimingFunction: {
|
||||
'css': 'ease',
|
||||
'css-in': 'ease-in',
|
||||
'css-out': 'ease-out',
|
||||
'css-in-out': 'ease-in-out',
|
||||
'in-sine': 'cubic-bezier(0.12, 0, 0.39, 0)',
|
||||
'out-sine': 'cubic-bezier(0.61, 1, 0.88, 1)',
|
||||
'in-out-sine': 'cubic-bezier(0.37, 0, 0.63, 1)',
|
||||
'in-quad': 'cubic-bezier(0.11, 0, 0.5, 0)',
|
||||
'out-quad': 'cubic-bezier(0.5, 1, 0.89, 1)',
|
||||
'in-out-quad': 'cubic-bezier(0.45, 0, 0.55, 1)',
|
||||
'in-cubic': 'cubic-bezier(0.32, 0, 0.67, 0)',
|
||||
'out-cubic': 'cubic-bezier(0.33, 1, 0.68, 1)',
|
||||
'in-out-cubic': 'cubic-bezier(0.65, 0, 0.35, 1)',
|
||||
'in-quart': 'cubic-bezier(0.5, 0, 0.75, 0)',
|
||||
'out-quart': 'cubic-bezier(0.25, 1, 0.5, 1)',
|
||||
'in-out-quart': 'cubic-bezier(0.76, 0, 0.24, 1)',
|
||||
'in-quint': 'cubic-bezier(0.64, 0, 0.78, 0)',
|
||||
'out-quint': 'cubic-bezier(0.22, 1, 0.36, 1)',
|
||||
'in-out-quint': 'cubic-bezier(0.83, 0, 0.17, 1)',
|
||||
'in-expo': 'cubic-bezier(0.7, 0, 0.84, 0)',
|
||||
'out-expo': 'cubic-bezier(0.16, 1, 0.3, 1)',
|
||||
'in-out-expo': 'cubic-bezier(0.87, 0, 0.13, 1)',
|
||||
'in-circ': 'cubic-bezier(0.55, 0, 1, 0.45)',
|
||||
'out-circ': 'cubic-bezier(0, 0.55, 0.45, 1)',
|
||||
'in-out-circ': 'cubic-bezier(0.85, 0, 0.15, 1)',
|
||||
'in-back': 'cubic-bezier(0.36, 0, 0.66, -0.56)',
|
||||
'out-back': 'cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||
'in-out-back': 'cubic-bezier(0.68, -0.6, 0.32, 1.6)'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
require('@tailwindcss/forms'),
|
||||
require('tailwindcss-animate'),
|
||||
require('@headlessui/tailwindcss'),
|
||||
require('tailwindcss-radix')(),
|
||||
require('@tailwindcss/typography')
|
||||
]
|
||||
};
|
||||
/**
|
||||
* @type {import('tailwindcss').Config}
|
||||
*/
|
||||
let config = {
|
||||
content: [
|
||||
`../../apps/${app}/src/**/*.{ts,tsx,html,stories.tsx}`,
|
||||
"../../packages/*/src/**/*.{ts,tsx,html,stories.tsx}",
|
||||
"../../interface/**/*.{ts,tsx,html,stories.tsx}",
|
||||
],
|
||||
darkMode: "class",
|
||||
theme: {
|
||||
screens: {
|
||||
xs: "475px",
|
||||
sm: "650px",
|
||||
md: "868px",
|
||||
lg: "1024px",
|
||||
xl: "1280px",
|
||||
},
|
||||
fontFamily: {
|
||||
sans: [...defaultTheme.fontFamily.sans],
|
||||
plex: ["IBM Plex Sans", ...defaultTheme.fontFamily.sans],
|
||||
},
|
||||
fontSize: {
|
||||
tiny: ".70rem",
|
||||
xs: ".75rem",
|
||||
sm: ".80rem",
|
||||
base: "1rem",
|
||||
lg: "1.125rem",
|
||||
xl: "1.25rem",
|
||||
"2xl": "1.5rem",
|
||||
"3xl": "1.875rem",
|
||||
"4xl": "2.25rem",
|
||||
"5xl": "3rem",
|
||||
"6xl": "4rem",
|
||||
"7xl": "5rem",
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
accent: {
|
||||
DEFAULT: alpha("--color-accent"),
|
||||
faint: "hsl(var(--color-accent-faint))",
|
||||
deep: alpha("--color-accent-deep"),
|
||||
},
|
||||
ink: {
|
||||
DEFAULT: alpha("--color-ink"),
|
||||
dull: alpha("--color-ink-dull"),
|
||||
faint: alpha("--color-ink-faint"),
|
||||
},
|
||||
sidebar: {
|
||||
DEFAULT: alpha("--color-sidebar"),
|
||||
box: alpha("--color-sidebar-box"),
|
||||
line: alpha("--color-sidebar-line"),
|
||||
ink: alpha("--color-sidebar-ink"),
|
||||
inkFaint: alpha("--color-sidebar-ink-faint"),
|
||||
inkDull: alpha("--color-sidebar-ink-dull"),
|
||||
divider: alpha("--color-sidebar-divider"),
|
||||
button: alpha("--color-sidebar-button"),
|
||||
selected: alpha("--color-sidebar-selected"),
|
||||
shade: alpha("--color-sidebar-shade"),
|
||||
},
|
||||
app: {
|
||||
DEFAULT: alpha("--color-app"),
|
||||
box: alpha("--color-app-box"),
|
||||
darkBox: alpha("--color-app-dark-box"),
|
||||
darkerBox: alpha("--color-app-darker-box"),
|
||||
lightBox: alpha("--color-app-light-box"),
|
||||
overlay: alpha("--color-app-overlay"),
|
||||
input: alpha("--color-app-input"),
|
||||
focus: alpha("--color-app-focus"),
|
||||
line: alpha("--color-app-line"),
|
||||
divider: alpha("--color-app-divider"),
|
||||
button: alpha("--color-app-button"),
|
||||
selected: alpha("--color-app-selected"),
|
||||
selectedItem: alpha("--color-app-selected-item"),
|
||||
hover: alpha("--color-app-hover"),
|
||||
active: alpha("--color-app-active"),
|
||||
shade: alpha("--color-app-shade"),
|
||||
frame: alpha("--color-app-frame"),
|
||||
slider: alpha("--color-app-slider"),
|
||||
explorerScrollbar: alpha("--color-app-explorer-scrollbar"),
|
||||
},
|
||||
menu: {
|
||||
DEFAULT: alpha("--color-menu"),
|
||||
line: alpha("--color-menu-line"),
|
||||
hover: alpha("--color-menu-hover"),
|
||||
selected: alpha("--color-menu-selected"),
|
||||
shade: alpha("--color-menu-shade"),
|
||||
ink: alpha("--color-menu-ink"),
|
||||
faint: alpha("--color-menu-faint"),
|
||||
},
|
||||
// legacy support
|
||||
primary: {
|
||||
DEFAULT: "#2599FF",
|
||||
50: "#FFFFFF",
|
||||
100: "#F1F8FF",
|
||||
200: "#BEE1FF",
|
||||
300: "#8BC9FF",
|
||||
400: "#58B1FF",
|
||||
500: "#2599FF",
|
||||
600: "#0081F1",
|
||||
700: "#0065BE",
|
||||
800: "#004A8B",
|
||||
900: "#002F58",
|
||||
},
|
||||
gray: {
|
||||
DEFAULT: "#505468",
|
||||
50: "#F1F1F4",
|
||||
100: "#E8E9ED",
|
||||
150: "#E0E1E6",
|
||||
200: "#D8DAE3",
|
||||
250: "#D2D4DC",
|
||||
300: "#C0C2CE",
|
||||
350: "#A6AABF",
|
||||
400: "#9196A8",
|
||||
450: "#71758A",
|
||||
500: "#303544",
|
||||
550: "#20222d",
|
||||
600: "#171720",
|
||||
650: "#121219",
|
||||
700: "#121317",
|
||||
750: "#0D0E11",
|
||||
800: "#0C0C0F",
|
||||
850: "#08090D",
|
||||
900: "#060609",
|
||||
950: "#030303",
|
||||
},
|
||||
},
|
||||
extend: {
|
||||
transitionTimingFunction: {
|
||||
css: "ease",
|
||||
"css-in": "ease-in",
|
||||
"css-out": "ease-out",
|
||||
"css-in-out": "ease-in-out",
|
||||
"in-sine": "cubic-bezier(0.12, 0, 0.39, 0)",
|
||||
"out-sine": "cubic-bezier(0.61, 1, 0.88, 1)",
|
||||
"in-out-sine": "cubic-bezier(0.37, 0, 0.63, 1)",
|
||||
"in-quad": "cubic-bezier(0.11, 0, 0.5, 0)",
|
||||
"out-quad": "cubic-bezier(0.5, 1, 0.89, 1)",
|
||||
"in-out-quad": "cubic-bezier(0.45, 0, 0.55, 1)",
|
||||
"in-cubic": "cubic-bezier(0.32, 0, 0.67, 0)",
|
||||
"out-cubic": "cubic-bezier(0.33, 1, 0.68, 1)",
|
||||
"in-out-cubic": "cubic-bezier(0.65, 0, 0.35, 1)",
|
||||
"in-quart": "cubic-bezier(0.5, 0, 0.75, 0)",
|
||||
"out-quart": "cubic-bezier(0.25, 1, 0.5, 1)",
|
||||
"in-out-quart": "cubic-bezier(0.76, 0, 0.24, 1)",
|
||||
"in-quint": "cubic-bezier(0.64, 0, 0.78, 0)",
|
||||
"out-quint": "cubic-bezier(0.22, 1, 0.36, 1)",
|
||||
"in-out-quint": "cubic-bezier(0.83, 0, 0.17, 1)",
|
||||
"in-expo": "cubic-bezier(0.7, 0, 0.84, 0)",
|
||||
"out-expo": "cubic-bezier(0.16, 1, 0.3, 1)",
|
||||
"in-out-expo": "cubic-bezier(0.87, 0, 0.13, 1)",
|
||||
"in-circ": "cubic-bezier(0.55, 0, 1, 0.45)",
|
||||
"out-circ": "cubic-bezier(0, 0.55, 0.45, 1)",
|
||||
"in-out-circ": "cubic-bezier(0.85, 0, 0.15, 1)",
|
||||
"in-back": "cubic-bezier(0.36, 0, 0.66, -0.56)",
|
||||
"out-back": "cubic-bezier(0.34, 1.56, 0.64, 1)",
|
||||
"in-out-back": "cubic-bezier(0.68, -0.6, 0.32, 1.6)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
require("@tailwindcss/forms"),
|
||||
require("tailwindcss-animate"),
|
||||
require("@headlessui/tailwindcss"),
|
||||
require("tailwindcss-radix")(),
|
||||
require("@tailwindcss/typography"),
|
||||
],
|
||||
};
|
||||
|
||||
if (app === 'landing') {
|
||||
console.log('CONFIGURING TAILWIND for Landing');
|
||||
config.theme.fontFamily.sans = ['var(--font-inter)', ...defaultTheme.fontFamily.sans];
|
||||
config.theme.fontFamily.plex = ['var(--font-plex-sans)', ...defaultTheme.fontFamily.sans];
|
||||
}
|
||||
if (app === "landing") {
|
||||
console.log("CONFIGURING TAILWIND for Landing");
|
||||
config.theme.fontFamily.sans = [
|
||||
"var(--font-inter)",
|
||||
...defaultTheme.fontFamily.sans,
|
||||
];
|
||||
config.theme.fontFamily.plex = [
|
||||
"var(--font-plex-sans)",
|
||||
...defaultTheme.fontFamily.sans,
|
||||
];
|
||||
}
|
||||
|
||||
return config;
|
||||
return config;
|
||||
};
|
||||
|
||||
// primary: {
|
||||
|
||||
@@ -92,7 +92,10 @@ pub fn generate_cargo_config(
|
||||
|
||||
let android_ndk_home = std::env::var("ANDROID_NDK")
|
||||
.or_else(|_| std::env::var("ANDROID_NDK_HOME"))
|
||||
.expect("Android NDK not found. Set ANDROID_NDK or ANDROID_NDK_HOME");
|
||||
.unwrap_or_else(|_| {
|
||||
println!(" ⚠️ Android NDK not found. Android builds will not work.");
|
||||
String::new()
|
||||
});
|
||||
|
||||
// Build context for mustache
|
||||
let context = ConfigContext {
|
||||
|
||||
Reference in New Issue
Block a user