diff --git a/Cargo.lock b/Cargo.lock
index 0d759a4c6e..1465878c16 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -177,9 +177,9 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aho-corasick"
-version = "1.1.3"
+version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
dependencies = [
"memchr",
]
@@ -1174,6 +1174,7 @@ dependencies = [
name = "cap-desktop"
version = "0.3.84"
dependencies = [
+ "aho-corasick",
"anyhow",
"async-stream",
"axum",
@@ -1221,9 +1222,11 @@ dependencies = [
"png 0.17.16",
"posthog-rs",
"rand 0.8.5",
+ "regex",
"relative-path",
"reqwest 0.12.24",
"rodio",
+ "sanitize-filename",
"scap-direct3d",
"scap-screencapturekit",
"scap-targets",
@@ -1598,12 +1601,14 @@ dependencies = [
name = "cap-utils"
version = "0.1.0"
dependencies = [
+ "aho-corasick",
"directories 5.0.1",
"flume",
"futures",
"nix 0.29.0",
"serde",
"serde_json",
+ "tempfile",
"tokio",
"tracing",
"uuid",
@@ -7638,6 +7643,15 @@ dependencies = [
"winapi-util",
]
+[[package]]
+name = "sanitize-filename"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc984f4f9ceb736a7bb755c3e3bd17dc56370af2600c9780dcc48c66453da34d"
+dependencies = [
+ "regex",
+]
+
[[package]]
name = "scap-cpal"
version = "0.1.0"
@@ -8496,6 +8510,7 @@ version = "2.0.0-rc.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ccbb212565d2dc177bc15ecb7b039d66c4490da892436a4eee5b394d620c9bc"
dependencies = [
+ "chrono",
"paste",
"serde_json",
"specta-macros",
diff --git a/Cargo.toml b/Cargo.toml
index 0b35f66d45..34b18cf08f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,11 @@
[workspace]
resolver = "2"
-members = ["apps/cli", "apps/desktop/src-tauri", "crates/*", "crates/workspace-hack"]
+members = [
+ "apps/cli",
+ "apps/desktop/src-tauri",
+ "crates/*",
+ "crates/workspace-hack",
+]
[workspace.dependencies]
anyhow = { version = "1.0.86" }
@@ -22,6 +27,7 @@ specta = { version = "=2.0.0-rc.20", features = [
"derive",
"serde_json",
"uuid",
+ "chrono"
] }
serde = { version = "1", features = ["derive"] }
@@ -40,6 +46,7 @@ sentry = { version = "0.42.0", features = [
] }
tracing = "0.1.41"
futures = "0.3.31"
+aho-corasick = "1.1.4"
cidre = { git = "https://github.com/CapSoftware/cidre", rev = "bf84b67079a8", features = [
"macos_12_7",
diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml
index 46024fd0ab..cf8a414ff1 100644
--- a/apps/desktop/src-tauri/Cargo.toml
+++ b/apps/desktop/src-tauri/Cargo.toml
@@ -20,11 +20,11 @@ swift-rs = { version = "1.0.6", features = ["build"] }
[dependencies]
tauri = { workspace = true, features = [
- "macos-private-api",
- "protocol-asset",
- "tray-icon",
- "image-png",
- "devtools",
+ "macos-private-api",
+ "protocol-asset",
+ "tray-icon",
+ "image-png",
+ "devtools",
] }
tauri-specta = { version = "=2.0.0-rc.20", features = ["derive", "typescript"] }
tauri-plugin-dialog = "2.2.0"
@@ -60,6 +60,7 @@ tracing.workspace = true
tempfile = "3.9.0"
ffmpeg.workspace = true
chrono = { version = "0.4.31", features = ["serde"] }
+regex = "1.10.4"
rodio = "0.19.0"
png = "0.17.13"
device_query = "4.0.1"
@@ -106,22 +107,24 @@ tauri-plugin-sentry = "0.5.0"
thiserror.workspace = true
bytes = "1.10.1"
async-stream = "0.3.6"
+sanitize-filename = "0.6.0"
tracing-futures = { version = "0.2.5", features = ["futures-03"] }
tracing-opentelemetry = "0.32.0"
opentelemetry = "0.31.0"
-opentelemetry-otlp = "0.31.0" #{ version = , features = ["http-proto", "reqwest-client"] }
+opentelemetry-otlp = "0.31.0" #{ version = , features = ["http-proto", "reqwest-client"] }
opentelemetry_sdk = { version = "0.31.0", features = ["rt-tokio", "trace"] }
posthog-rs = "0.3.7"
workspace-hack = { version = "0.1", path = "../../../crates/workspace-hack" }
+aho-corasick.workspace = true
[target.'cfg(target_os = "macos")'.dependencies]
core-graphics = "0.24.0"
core-foundation = "0.10.0"
objc2-app-kit = { version = "0.3.0", features = [
- "NSWindow",
- "NSResponder",
- "NSHapticFeedback",
+ "NSWindow",
+ "NSResponder",
+ "NSHapticFeedback",
] }
cocoa = "0.26.0"
objc = "0.2.7"
@@ -131,10 +134,10 @@ cidre = { workspace = true }
[target.'cfg(target_os= "windows")'.dependencies]
windows = { workspace = true, features = [
- "Win32_Foundation",
- "Win32_System",
- "Win32_UI_WindowsAndMessaging",
- "Win32_Graphics_Gdi",
+ "Win32_Foundation",
+ "Win32_System",
+ "Win32_UI_WindowsAndMessaging",
+ "Win32_Graphics_Gdi",
] }
windows-sys = { workspace = true }
diff --git a/apps/desktop/src-tauri/src/general_settings.rs b/apps/desktop/src-tauri/src/general_settings.rs
index 1b193e75b3..1d3fc7fa61 100644
--- a/apps/desktop/src-tauri/src/general_settings.rs
+++ b/apps/desktop/src-tauri/src/general_settings.rs
@@ -122,6 +122,8 @@ pub struct GeneralSettingsStore {
pub delete_instant_recordings_after_upload: bool,
#[serde(default = "default_instant_mode_max_resolution")]
pub instant_mode_max_resolution: u32,
+ #[serde(default)]
+ pub default_project_name_template: Option,
}
fn default_enable_native_camera_preview() -> bool {
@@ -187,6 +189,7 @@ impl Default for GeneralSettingsStore {
excluded_windows: default_excluded_windows(),
delete_instant_recordings_after_upload: false,
instant_mode_max_resolution: 1920,
+ default_project_name_template: None,
}
}
}
diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs
index e3c3f1ffa0..b268fb7b4f 100644
--- a/apps/desktop/src-tauri/src/lib.rs
+++ b/apps/desktop/src-tauri/src/lib.rs
@@ -25,6 +25,7 @@ mod recording_settings;
mod target_select_overlay;
mod thumbnails;
mod tray;
+mod update_project_names;
mod upload;
mod web_api;
mod window_exclusion;
@@ -2298,7 +2299,8 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) {
target_select_overlay::display_information,
target_select_overlay::get_window_icon,
target_select_overlay::focus_window,
- editor_delete_project
+ editor_delete_project,
+ format_project_name,
])
.events(tauri_specta::collect_events![
RecordingOptionsChanged,
@@ -2442,6 +2444,11 @@ pub async fn run(recording_logging_handle: LoggingHandle, logs_dir: PathBuf) {
.invoke_handler(specta_builder.invoke_handler())
.setup(move |app| {
let app = app.handle().clone();
+
+ if let Err(err) = update_project_names::migrate_if_needed(&app) {
+ tracing::error!("Failed to migrate project file names: {}", err);
+ }
+
specta_builder.mount_events(&app);
hotkeys::init(&app);
general_settings::init(&app);
@@ -3052,6 +3059,24 @@ async fn write_clipboard_string(
.map_err(|e| format!("Failed to write text to clipboard: {e}"))
}
+#[tauri::command(async)]
+#[specta::specta]
+fn format_project_name(
+ template: Option,
+ target_name: String,
+ target_kind: String,
+ recording_mode: RecordingMode,
+ datetime: Option>,
+) -> String {
+ recording::format_project_name(
+ template.as_deref(),
+ target_name.as_str(),
+ target_kind.as_str(),
+ recording_mode,
+ datetime,
+ )
+}
+
trait EventExt: tauri_specta::Event {
fn listen_any_spawn(
app: &AppHandle,
diff --git a/apps/desktop/src-tauri/src/recording.rs b/apps/desktop/src-tauri/src/recording.rs
index 1c821f87b2..8deb4f55d6 100644
--- a/apps/desktop/src-tauri/src/recording.rs
+++ b/apps/desktop/src-tauri/src/recording.rs
@@ -23,10 +23,13 @@ use cap_recording::{
studio_recording,
};
use cap_rendering::ProjectRecordingsMeta;
-use cap_utils::{ensure_dir, spawn_actor};
+use cap_utils::{ensure_dir, moment_format_to_chrono, spawn_actor};
use futures::{FutureExt, stream};
+use lazy_static::lazy_static;
+use regex::Regex;
use serde::{Deserialize, Serialize};
use specta::Type;
+use std::borrow::Cow;
use std::{
any::Any,
collections::{HashMap, VecDeque},
@@ -349,6 +352,122 @@ pub enum RecordingAction {
UpgradeRequired,
}
+/// Formats the project name using a template string.
+///
+/// # Template Variables
+///
+/// The template supports the following variables that will be replaced with actual values:
+///
+/// ## Recording Mode Variables
+/// - `{recording_mode}` - The recording mode: "Studio" or "Instant"
+/// - `{mode}` - Short form of recording mode: "studio" or "instant"
+///
+/// ## Target Variables
+/// - `{target_kind}` - The type of capture target: "Display", "Window", or "Area"
+/// - `{target_name}` - The specific name of the target (e.g., "Built-in Retina Display", "Chrome", etc.)
+///
+/// ## Date/Time Variables
+/// - `{date}` - Current date in YYYY-MM-DD format (e.g., "2025-09-11")
+/// - `{time}` - Current time in HH:MM AM/PM format (e.g., "3:23 PM")
+///
+/// ## Customizable Date/Time Formats
+/// You can customize date and time formats by adding moment format specifiers:
+/// - `{moment:YYYY-MM-DD}` - Custom date format
+/// - `{moment:HH:mm}` - 24-hour time format
+/// - `{moment:hh:mm A}` - 12-hour time with AM/PM
+/// - `{moment:YYYY-MM-DD HH:mm}` - Combined custom format
+///
+/// ## Example
+///
+/// `{recording_mode} Recording {target_kind} ({target_name}) {date} {time}`
+/// -> "Instant Recording Display (Built-in Retina Display) 2025-11-12 3:23 PM"
+///
+/// # Arguments
+///
+/// * `template` - The template string with variables to replace
+/// * `target_name` - The specific name of the capture target
+/// * `target_kind` - The type of capture target (Display, Window, or Area)
+/// * `recording_mode` - The recording mode (Studio or Instant)
+/// * `datetime` - Optional datetime to use for formatting; defaults to current time
+///
+/// # Returns
+///
+/// Returns `String` with the formatted project name
+pub fn format_project_name<'a>(
+ template: Option<&str>,
+ target_name: &'a str,
+ target_kind: &'a str,
+ recording_mode: RecordingMode,
+ datetime: Option>,
+) -> String {
+ const DEFAULT_FILENAME_TEMPLATE: &str = "{target_name} ({target_kind}) {date} {time}";
+ let datetime = datetime.unwrap_or(chrono::Local::now());
+
+ lazy_static! {
+ static ref DATE_REGEX: Regex = Regex::new(r"\{date(?::([^}]+))?\}").unwrap();
+ static ref TIME_REGEX: Regex = Regex::new(r"\{time(?::([^}]+))?\}").unwrap();
+ static ref MOMENT_REGEX: Regex = Regex::new(r"\{moment(?::([^}]+))?\}").unwrap();
+ static ref AC: aho_corasick::AhoCorasick = {
+ aho_corasick::AhoCorasick::new([
+ "{recording_mode}",
+ "{mode}",
+ "{target_kind}",
+ "{target_name}",
+ ])
+ .expect("Failed to build AhoCorasick automaton")
+ };
+ }
+ let haystack = template.unwrap_or(DEFAULT_FILENAME_TEMPLATE);
+
+ // Get recording mode information
+ let (recording_mode, mode) = match recording_mode {
+ RecordingMode::Studio => ("Studio", "studio"),
+ RecordingMode::Instant => ("Instant", "instant"),
+ };
+
+ let result = AC
+ .try_replace_all(haystack, &[recording_mode, mode, target_kind, target_name])
+ .expect("AhoCorasick replace should never fail with default configuration");
+
+ let result = DATE_REGEX.replace_all(&result, |caps: ®ex::Captures| {
+ datetime
+ .format(
+ &caps
+ .get(1)
+ .map(|m| m.as_str())
+ .map(moment_format_to_chrono)
+ .unwrap_or(Cow::Borrowed("%Y-%m-%d")),
+ )
+ .to_string()
+ });
+
+ let result = TIME_REGEX.replace_all(&result, |caps: ®ex::Captures| {
+ datetime
+ .format(
+ &caps
+ .get(1)
+ .map(|m| m.as_str())
+ .map(moment_format_to_chrono)
+ .unwrap_or(Cow::Borrowed("%I:%M %p")),
+ )
+ .to_string()
+ });
+
+ let result = MOMENT_REGEX.replace_all(&result, |caps: ®ex::Captures| {
+ datetime
+ .format(
+ &caps
+ .get(1)
+ .map(|m| m.as_str())
+ .map(moment_format_to_chrono)
+ .unwrap_or(Cow::Borrowed("%Y-%m-%d %H:%M")),
+ )
+ .to_string()
+ });
+
+ result.into_owned()
+}
+
#[tauri::command]
#[specta::specta]
#[tracing::instrument(name = "recording", skip_all)]
@@ -361,16 +480,32 @@ pub async fn start_recording(
return Err("Recording already in progress".to_string());
}
- let id = uuid::Uuid::new_v4().to_string();
let general_settings = GeneralSettingsStore::get(&app).ok().flatten();
let general_settings = general_settings.as_ref();
- let recording_dir = app
- .path()
- .app_data_dir()
- .unwrap()
- .join("recordings")
- .join(format!("{id}.cap"));
+ let project_name = format_project_name(
+ general_settings
+ .and_then(|s| s.default_project_name_template.clone())
+ .as_deref(),
+ inputs
+ .capture_target
+ .title()
+ .as_deref()
+ .unwrap_or("Unknown"),
+ inputs.capture_target.kind_str(),
+ inputs.mode,
+ None,
+ );
+
+ let filename = project_name.replace(":", ".");
+ let filename = format!("{}.cap", sanitize_filename::sanitize(&filename));
+
+ let recordings_base_dir = app.path().app_data_dir().unwrap().join("recordings");
+
+ let recording_dir = recordings_base_dir.join(&cap_utils::ensure_unique_filename(
+ &filename,
+ &recordings_base_dir,
+ )?);
ensure_dir(&recording_dir).map_err(|e| format!("Failed to create recording directory: {e}"))?;
state_mtx
@@ -379,16 +514,6 @@ pub async fn start_recording(
.add_recording_logging_handle(&recording_dir.join("recording-logs.log"))
.await?;
- let target_name = {
- let title = inputs.capture_target.title();
-
- match inputs.capture_target.clone() {
- ScreenCaptureTarget::Area { .. } => title.unwrap_or_else(|| "Area".to_string()),
- ScreenCaptureTarget::Window { .. } => title.unwrap_or_else(|| "Window".to_string()),
- ScreenCaptureTarget::Display { .. } => title.unwrap_or_else(|| "Screen".to_string()),
- }
- };
-
if let Some(window) = CapWindowId::Camera.get(&app) {
let _ = window.set_content_protected(matches!(inputs.mode, RecordingMode::Studio));
}
@@ -402,10 +527,7 @@ pub async fn start_recording(
&app,
false,
None,
- Some(format!(
- "{target_name} {}",
- chrono::Local::now().format("%Y-%m-%d %H:%M:%S")
- )),
+ Some(project_name.clone()),
None,
inputs.organization_id.clone(),
)
@@ -443,17 +565,10 @@ pub async fn start_recording(
RecordingMode::Studio => None,
};
- let date_time = if cfg!(windows) {
- // Windows doesn't support colon in file paths
- chrono::Local::now().format("%Y-%m-%d %H.%M.%S")
- } else {
- chrono::Local::now().format("%Y-%m-%d %H:%M:%S")
- };
-
let meta = RecordingMeta {
platform: Some(Platform::default()),
project_path: recording_dir.clone(),
- pretty_name: format!("{target_name} {date_time}"),
+ pretty_name: project_name.clone(),
inner: match inputs.mode {
RecordingMode::Studio => {
RecordingMetaInner::Studio(StudioRecordingMeta::MultipleSegments {
@@ -541,7 +656,7 @@ pub async fn start_recording(
let state_mtx = Arc::clone(&state_mtx);
let general_settings = general_settings.cloned();
let recording_dir = recording_dir.clone();
- let target_name = target_name.clone();
+ // let project_name = project_name.clone();
let inputs = inputs.clone();
async move {
fail!("recording::spawn_actor");
@@ -603,7 +718,7 @@ pub async fn start_recording(
acquire_shareable_content_for_target(&inputs.capture_target).await?;
let common = InProgressRecordingCommon {
- target_name,
+ target_name: project_name,
inputs: inputs.clone(),
recording_dir: recording_dir.clone(),
};
diff --git a/apps/desktop/src-tauri/src/update_project_names.rs b/apps/desktop/src-tauri/src/update_project_names.rs
new file mode 100644
index 0000000000..f52737ab76
--- /dev/null
+++ b/apps/desktop/src-tauri/src/update_project_names.rs
@@ -0,0 +1,322 @@
+use std::{
+ collections::HashSet,
+ path::{Path, PathBuf},
+ sync::Arc,
+};
+
+use cap_project::RecordingMeta;
+use futures::StreamExt;
+use tauri::AppHandle;
+use tokio::{fs, sync::Mutex};
+
+use crate::recordings_path;
+
+const STORE_KEY: &str = "uuid_projects_migrated";
+
+pub fn migrate_if_needed(app: &AppHandle) -> Result<(), String> {
+ use tauri_plugin_store::StoreExt;
+
+ let store = app
+ .store("store")
+ .map_err(|e| format!("Failed to access store: {}", e))?;
+
+ if store
+ .get(STORE_KEY)
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false)
+ {
+ return Ok(());
+ }
+
+ if let Err(err) = futures::executor::block_on(migrate(app)) {
+ tracing::error!("Updating project names failed: {err}");
+ }
+
+ store.set(STORE_KEY, true);
+ store
+ .save()
+ .map_err(|e| format!("Failed to save store: {}", e))?;
+
+ Ok(())
+}
+
+use std::time::Instant;
+
+/// Performs a one-time migration of all UUID-named projects to pretty name-based naming.
+pub async fn migrate(app: &AppHandle) -> Result<(), String> {
+ let recordings_dir = recordings_path(app);
+ if !fs::try_exists(&recordings_dir)
+ .await
+ .map_err(|e| format!("Failed to check recordings directory: {}", e))?
+ {
+ return Ok(());
+ }
+
+ let uuid_projects = collect_uuid_projects(&recordings_dir).await?;
+ if uuid_projects.is_empty() {
+ tracing::debug!("No UUID-named projects found to migrate");
+ return Ok(());
+ }
+
+ tracing::info!(
+ "Found {} UUID-named projects to migrate",
+ uuid_projects.len()
+ );
+
+ let total_found = uuid_projects.len();
+ let concurrency_limit = std::thread::available_parallelism()
+ .map(|n| n.get())
+ .unwrap_or(4)
+ .clamp(2, 16)
+ .min(total_found);
+ tracing::debug!("Using concurrency limit of {}", concurrency_limit);
+
+ let wall_start = Instant::now();
+ let in_flight_bases = Arc::new(Mutex::new(HashSet::new()));
+
+ // (project_name, result, duration)
+ let migration_results = futures::stream::iter(uuid_projects)
+ .map(|project_path| {
+ let in_flight = in_flight_bases.clone();
+ async move {
+ let project_name = project_path
+ .file_name()
+ .map(|s| s.to_string_lossy().into_owned())
+ .unwrap_or_else(|| project_path.display().to_string());
+
+ let start = Instant::now();
+ let res = migrate_single_project(project_path, in_flight).await;
+ let dur = start.elapsed();
+
+ (project_name, res, dur)
+ }
+ })
+ .buffer_unordered(concurrency_limit)
+ .collect::>()
+ .await;
+
+ let wall_elapsed = wall_start.elapsed();
+
+ let mut migrated = 0usize;
+ let mut skipped = 0usize;
+ let mut failed = 0usize;
+
+ let mut total_ms: u128 = 0;
+ let mut per_project: Vec<(String, std::time::Duration)> =
+ Vec::with_capacity(migration_results.len());
+
+ for (name, result, dur) in migration_results.into_iter() {
+ match result {
+ Ok(ProjectMigrationResult::Migrated) => migrated += 1,
+ Ok(ProjectMigrationResult::Skipped) => skipped += 1,
+ Err(_) => failed += 1,
+ }
+ total_ms += dur.as_millis();
+ per_project.push((name, dur));
+ }
+
+ let avg_ms = if total_found > 0 {
+ (total_ms as f64) / (total_found as f64)
+ } else {
+ 0.0
+ };
+
+ // Sort by duration descending to pick slowest
+ per_project.sort_by(|a, b| b.1.cmp(&a.1));
+
+ tracing::info!(
+ total_found = total_found,
+ migrated = migrated,
+ skipped = skipped,
+ failed = failed,
+ wall_ms = wall_elapsed.as_millis(),
+ avg_per_project_ms = ?avg_ms,
+ "Migration complete"
+ );
+
+ // Log top slowest N (choose 5 or less)
+ let top_n = 5.min(per_project.len());
+ if top_n > 0 {
+ tracing::info!("Top {} slowest project migrations:", top_n);
+ for (name, dur) in per_project.into_iter().take(top_n) {
+ tracing::info!(project = %name, ms = dur.as_millis());
+ }
+ }
+
+ Ok(())
+}
+
+async fn collect_uuid_projects(recordings_dir: &Path) -> Result, String> {
+ let mut uuid_projects = Vec::new();
+ let mut entries = fs::read_dir(recordings_dir)
+ .await
+ .map_err(|e| format!("Failed to read recordings directory: {}", e))?;
+
+ while let Some(entry) = entries
+ .next_entry()
+ .await
+ .map_err(|e| format!("Failed to read directory entry: {}", e))?
+ {
+ let path = entry.path();
+ if !path.is_dir() {
+ continue;
+ }
+
+ let Some(filename) = path.file_name().and_then(|s| s.to_str()) else {
+ continue;
+ };
+
+ if filename.ends_with(".cap") && fast_is_project_filename_uuid(filename) {
+ uuid_projects.push(path);
+ }
+ }
+
+ Ok(uuid_projects)
+}
+
+#[derive(Debug)]
+enum ProjectMigrationResult {
+ Migrated,
+ Skipped,
+}
+
+async fn migrate_single_project(
+ path: PathBuf,
+ in_flight_basis: Arc>>,
+) -> Result {
+ let filename = path
+ .file_name()
+ .and_then(|s| s.to_str())
+ .unwrap_or("unknown");
+
+ let meta = match RecordingMeta::load_for_project(&path) {
+ Ok(meta) => meta,
+ Err(e) => {
+ tracing::warn!("Failed to load metadata for {}: {}", filename, e);
+ return Err(format!("Failed to load metadata: {}", e));
+ }
+ };
+
+ // Lock on the base sanitized name to prevent concurrent migrations with same target
+ let base_name = sanitize_filename::sanitize(meta.pretty_name.replace(":", "."));
+ {
+ let mut in_flight = in_flight_basis.lock().await;
+ let mut wait_count = 0;
+ while !in_flight.insert(base_name.clone()) {
+ wait_count += 1;
+ if wait_count == 1 {
+ tracing::debug!(
+ "Project {} waiting for concurrent migration of base name \"{}\"",
+ filename,
+ base_name
+ );
+ }
+ drop(in_flight);
+ tokio::time::sleep(std::time::Duration::from_millis(5)).await;
+ in_flight = in_flight_basis.lock().await;
+ }
+ if wait_count > 0 {
+ tracing::debug!(
+ "Project {} acquired lock for \"{}\" after {} waits",
+ filename,
+ base_name,
+ wait_count
+ );
+ }
+ }
+
+ let result = migrate_project_filename_async(&path, &meta).await;
+
+ in_flight_basis.lock().await.remove(&base_name);
+
+ match result {
+ Ok(new_path) => {
+ if new_path != path {
+ let new_name = new_path.file_name().unwrap().to_string_lossy();
+ tracing::info!("Updated name: \"{}\" -> \"{}\"", filename, new_name);
+ Ok(ProjectMigrationResult::Migrated)
+ } else {
+ Ok(ProjectMigrationResult::Skipped)
+ }
+ }
+ Err(e) => {
+ tracing::error!("Failed to migrate {}: {}", filename, e);
+ Err(e)
+ }
+ }
+}
+
+/// Migrates a project filename from UUID to sanitized pretty name
+async fn migrate_project_filename_async(
+ project_path: &Path,
+ meta: &RecordingMeta,
+) -> Result {
+ let sanitized = sanitize_filename::sanitize(meta.pretty_name.replace(":", "."));
+
+ let filename = if sanitized.ends_with(".cap") {
+ sanitized
+ } else {
+ format!("{}.cap", sanitized)
+ };
+
+ let parent_dir = project_path
+ .parent()
+ .ok_or("Project path has no parent directory")?;
+
+ let unique_filename = cap_utils::ensure_unique_filename(&filename, parent_dir)
+ .map_err(|e| format!("Failed to ensure unique filename: {}", e))?;
+
+ let final_path = parent_dir.join(&unique_filename);
+
+ fs::rename(project_path, &final_path)
+ .await
+ .map_err(|e| format!("Failed to rename project directory: {}", e))?;
+
+ Ok(final_path)
+}
+
+pub fn fast_is_project_filename_uuid(filename: &str) -> bool {
+ if filename.len() != 40 || !filename.ends_with(".cap") {
+ return false;
+ }
+
+ let uuid_part = &filename[..36];
+
+ if uuid_part.as_bytes()[8] != b'-'
+ || uuid_part.as_bytes()[13] != b'-'
+ || uuid_part.as_bytes()[18] != b'-'
+ || uuid_part.as_bytes()[23] != b'-'
+ {
+ return false;
+ }
+
+ uuid_part.chars().all(|c| c.is_ascii_hexdigit() || c == '-')
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn test_is_project_filename_uuid() {
+ // Valid UUID
+ assert!(fast_is_project_filename_uuid(
+ "a1b2c3d4-e5f6-7890-abcd-ef1234567890.cap"
+ ));
+ assert!(fast_is_project_filename_uuid(
+ "00000000-0000-0000-0000-000000000000.cap"
+ ));
+
+ // Invalid cases
+ assert!(!fast_is_project_filename_uuid("my-project-name.cap"));
+ assert!(!fast_is_project_filename_uuid(
+ "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
+ ));
+ assert!(!fast_is_project_filename_uuid(
+ "a1b2c3d4-e5f6-7890-abcd-ef1234567890.txt"
+ ));
+ assert!(!fast_is_project_filename_uuid(
+ "g1b2c3d4-e5f6-7890-abcd-ef1234567890.cap"
+ ));
+ }
+}
diff --git a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx
index 08b4846400..c4b21dd445 100644
--- a/apps/desktop/src/routes/(window-chrome)/settings/general.tsx
+++ b/apps/desktop/src/routes/(window-chrome)/settings/general.tsx
@@ -6,6 +6,7 @@ import {
} from "@tauri-apps/plugin-notification";
import { type OsType, type } from "@tauri-apps/plugin-os";
import "@total-typescript/ts-reset/filter-boolean";
+import { Collapsible } from "@kobalte/core/collapsible";
import { CheckMenuItem, Menu, MenuItem } from "@tauri-apps/api/menu";
import { confirm } from "@tauri-apps/plugin-dialog";
import { cx } from "cva";
@@ -13,7 +14,9 @@ import {
createEffect,
createMemo,
createResource,
+ createSignal,
For,
+ onMount,
type ParentProps,
Show,
} from "solid-js";
@@ -99,6 +102,9 @@ const INSTANT_MODE_RESOLUTION_OPTIONS = [
label: string;
}[];
+const DEFAULT_PROJECT_NAME_TEMPLATE =
+ "{target_name} ({target_kind}) {date} {time}";
+
export default function GeneralSettings() {
const [store] = createResource(() => generalSettingsStore.get());
@@ -537,6 +543,13 @@ function Inner(props: { initialStore: GeneralSettingsStore | null }) {
/>
+
+ handleChange("defaultProjectNameTemplate", value)
+ }
+ value={settings.defaultProjectNameTemplate ?? null}
+ />
+
Promise;
+}) {
+ const MOMENT_EXAMPLE_TEMPLATE = "{moment:DDDD, MMMM D, YYYY h:mm A}";
+ const macos = type() === "macos";
+ const today = new Date();
+ const datetime = new Date(
+ today.getFullYear(),
+ today.getMonth(),
+ today.getDate(),
+ macos ? 9 : 12,
+ macos ? 41 : 0,
+ 0,
+ 0,
+ ).toISOString();
+
+ let inputRef: HTMLInputElement | undefined;
+
+ const dateString = today.toISOString().split("T")[0];
+ const initialTemplate = () => props.value ?? DEFAULT_PROJECT_NAME_TEMPLATE;
+
+ const [inputValue, setInputValue] = createSignal(initialTemplate());
+ const [preview, setPreview] = createSignal(null);
+ const [momentExample, setMomentExample] = createSignal("");
+
+ async function updatePreview(val = inputValue()) {
+ const formatted = await commands.formatProjectName(
+ val,
+ macos ? "Safari" : "Chrome",
+ "Window",
+ "instant",
+ datetime,
+ );
+ setPreview(formatted);
+ }
+
+ onMount(() => {
+ commands
+ .formatProjectName(
+ MOMENT_EXAMPLE_TEMPLATE,
+ macos ? "Safari" : "Chrome",
+ "Window",
+ "instant",
+ datetime,
+ )
+ .then(setMomentExample);
+
+ const seed = initialTemplate();
+ setInputValue(seed);
+ if (inputRef) inputRef.value = seed;
+ updatePreview(seed);
+ });
+
+ const isSaveDisabled = () => {
+ const input = inputValue();
+ return (
+ !input ||
+ input === (props.value ?? DEFAULT_PROJECT_NAME_TEMPLATE) ||
+ input.length <= 3
+ );
+ };
+
+ function CodeView(props: { children: string }) {
+ return (
+ commands.writeClipboardString(props.children)}
+ >
+ {props.children}
+
+ );
+ }
+
+ return (
+
+
+
+
Default Project Name
+
+ Choose the template to use as the default project and file name.
+
+
+
+ {
+ await props.onChange(null);
+ const newTemplate = initialTemplate();
+ setInputValue(newTemplate);
+ if (inputRef) inputRef.value = newTemplate;
+ await updatePreview(newTemplate);
+ }}
+ >
+ Reset
+
+
+ {
+ await props.onChange(inputValue() ?? null);
+ await updatePreview();
+ }}
+ >
+ Save
+
+
+
+
+
+
{
+ setInputValue(e.currentTarget.value);
+ updatePreview(e.currentTarget.value);
+ }}
+ />
+
+
+
+
+
+
+ How to customize?
+
+
+
+
+ Use placeholders in your template that will be automatically
+ filled in.
+
+
+
+
Recording Mode
+
+ {"{recording_mode}"} → "Studio" or
+ "Instant"
+
+
+ {"{mode}"} → "studio" or "instant"
+
+
+
+
+
Target
+
+ {"{target_kind}"} → "Display", "Window", or
+ "Area"
+
+
+ {"{target_name}"} → The name of the monitor
+ or the title of the app depending on the recording mode.
+
+
+
+
+
Date & Time
+
+ {"{date}"} → {dateString}
+
+
+ {"{time}"} →{" "}
+ {macos ? "09:41 AM" : "12:00 PM"}
+
+
+
+
+
Custom Formats
+
+ You can also use a custom format for time. The placeholders are
+ case-sensitive. For 24-hour time, use{" "}
+ {"{moment:HH:mm}"} or use lower cased{" "}
+ hh for 12-hour format.
+
+
+ {MOMENT_EXAMPLE_TEMPLATE} →{" "}
+ {momentExample()}
+
+
+
+
+
+
+ );
+}
+
function ExcludedWindowsCard(props: {
excludedWindows: WindowExclusion[];
availableWindows: CaptureWindow[];
@@ -704,7 +919,7 @@ function ExcludedWindowsCard(props: {
-
+
{
},
async editorDeleteProject() : Promise {
return await TAURI_INVOKE("editor_delete_project");
+},
+async formatProjectName(template: string | null, targetName: string, targetKind: string, recordingMode: RecordingMode, datetime: string | null) : Promise {
+ return await TAURI_INVOKE("format_project_name", { template, targetName, targetKind, recordingMode, datetime });
}
}
@@ -404,7 +407,7 @@ export type ExportSettings = ({ format: "Mp4" } & Mp4ExportSettings) | ({ format
export type FileType = "recording" | "screenshot"
export type Flags = { captions: boolean }
export type FramesRendered = { renderedCount: number; totalFrames: number; type: "FramesRendered" }
-export type GeneralSettingsStore = { instanceId?: string; uploadIndividualFiles?: boolean; hideDockIcon?: boolean; autoCreateShareableLink?: boolean; enableNotifications?: boolean; disableAutoOpenLinks?: boolean; hasCompletedStartup?: boolean; theme?: AppTheme; commercialLicense?: CommercialLicense | null; lastVersion?: string | null; windowTransparency?: boolean; postStudioRecordingBehaviour?: PostStudioRecordingBehaviour; mainWindowRecordingStartBehaviour?: MainWindowRecordingStartBehaviour; custom_cursor_capture2?: boolean; serverUrl?: string; recordingCountdown?: number | null; enableNativeCameraPreview: boolean; autoZoomOnClicks?: boolean; enableNewRecordingFlow: boolean; recordingPickerPreferenceSet?: boolean; postDeletionBehaviour?: PostDeletionBehaviour; excludedWindows?: WindowExclusion[]; deleteInstantRecordingsAfterUpload?: boolean; instantModeMaxResolution?: number }
+export type GeneralSettingsStore = { instanceId?: string; uploadIndividualFiles?: boolean; hideDockIcon?: boolean; autoCreateShareableLink?: boolean; enableNotifications?: boolean; disableAutoOpenLinks?: boolean; hasCompletedStartup?: boolean; theme?: AppTheme; commercialLicense?: CommercialLicense | null; lastVersion?: string | null; windowTransparency?: boolean; postStudioRecordingBehaviour?: PostStudioRecordingBehaviour; mainWindowRecordingStartBehaviour?: MainWindowRecordingStartBehaviour; custom_cursor_capture2?: boolean; serverUrl?: string; recordingCountdown?: number | null; enableNativeCameraPreview: boolean; autoZoomOnClicks?: boolean; enableNewRecordingFlow: boolean; recordingPickerPreferenceSet?: boolean; postDeletionBehaviour?: PostDeletionBehaviour; excludedWindows?: WindowExclusion[]; deleteInstantRecordingsAfterUpload?: boolean; instantModeMaxResolution?: number; defaultProjectNameTemplate?: string | null }
export type GifExportSettings = { fps: number; resolution_base: XY; quality: GifQuality | null }
export type GifQuality = {
/**
diff --git a/crates/recording/src/sources/screen_capture/mod.rs b/crates/recording/src/sources/screen_capture/mod.rs
index 4120b047b0..0353d5bb57 100644
--- a/crates/recording/src/sources/screen_capture/mod.rs
+++ b/crates/recording/src/sources/screen_capture/mod.rs
@@ -193,6 +193,14 @@ impl ScreenCaptureTarget {
Self::Area { screen, .. } => Display::from_id(screen).and_then(|d| d.name()),
}
}
+
+ pub fn kind_str(&self) -> &str {
+ match self {
+ ScreenCaptureTarget::Display { .. } => "Display",
+ ScreenCaptureTarget::Window { .. } => "Window",
+ ScreenCaptureTarget::Area { .. } => "Area",
+ }
+ }
}
pub struct ScreenCaptureConfig {
diff --git a/crates/utils/Cargo.toml b/crates/utils/Cargo.toml
index c1b9db8e14..66c180a553 100644
--- a/crates/utils/Cargo.toml
+++ b/crates/utils/Cargo.toml
@@ -8,13 +8,13 @@ nix = { version = "0.29.0", features = ["fs"] }
[target.'cfg(windows)'.dependencies]
windows = { version = "0.58.0", features = [
- "Win32_Foundation",
- "Win32_System",
- "Win32_System_WindowsProgramming",
- "Win32_Security",
- "Win32_Storage_FileSystem",
- "Win32_System_Pipes",
- "Win32_System_Diagnostics_Debug",
+ "Win32_Foundation",
+ "Win32_System",
+ "Win32_System_WindowsProgramming",
+ "Win32_Security",
+ "Win32_Storage_FileSystem",
+ "Win32_System_Pipes",
+ "Win32_System_Diagnostics_Debug",
] }
windows-sys = "0.52.0"
@@ -27,7 +27,11 @@ serde_json = "1.0"
flume = "0.11.0"
tracing.workspace = true
directories = "5.0"
+aho-corasick.workspace = true
workspace-hack = { version = "0.1", path = "../workspace-hack" }
+[dev-dependencies]
+tempfile = "3"
+
[lints]
workspace = true
diff --git a/crates/utils/src/lib.rs b/crates/utils/src/lib.rs
index 73ef3f41db..7b0ed42f28 100644
--- a/crates/utils/src/lib.rs
+++ b/crates/utils/src/lib.rs
@@ -1,5 +1,12 @@
-use std::{future::Future, path::PathBuf};
+use std::{
+ borrow::Cow,
+ future::Future,
+ num::{NonZero, NonZeroI32},
+ path::PathBuf,
+ sync::LazyLock,
+};
+use aho_corasick::{AhoCorasickBuilder, MatchKind};
use tracing::Instrument;
/// Wrapper around tokio::spawn that inherits the current tracing subscriber and span.
@@ -16,3 +23,389 @@ pub fn ensure_dir(path: &PathBuf) -> Result {
std::fs::create_dir_all(path)?;
Ok(path.clone())
}
+
+/// Generates a unique filename by appending incremental numbers if conflicts exist.
+///
+/// This function takes a base filename and ensures it's unique by appending `(1)`, `(2)`, etc.
+/// if a file with the same name already exists. It works with any file extension.
+///
+/// # Arguments
+///
+/// * `base_filename` - The desired filename (with extension)
+/// * `parent_dir` - The directory where the file should be created
+///
+/// # Returns
+///
+/// Returns the unique filename that doesn't conflict with existing files.
+///
+/// # Example
+///
+/// ```rust
+/// let unique_name = ensure_unique_filename("My Recording.cap", &recordings_dir,);
+/// // If "My Recording.cap" exists, returns "My Recording (1).cap"
+/// // If that exists too, returns "My Recording (2).cap", etc.
+///
+/// let unique_name = ensure_unique_filename("document.pdf", &documents_dir);
+/// // If "document.pdf" exists, returns "document (1).pdf"
+/// ```
+#[inline]
+pub fn ensure_unique_filename(
+ base_filename: &str,
+ parent_dir: &std::path::Path,
+) -> Result {
+ const DEFAULT_MAX_ATTEMPTS: NonZero = NonZero::new(50).unwrap();
+ ensure_unique_filename_with_attempts(base_filename, parent_dir, DEFAULT_MAX_ATTEMPTS)
+}
+
+pub fn ensure_unique_filename_with_attempts(
+ base_filename: &str,
+ parent_dir: &std::path::Path,
+ attempts: NonZeroI32,
+) -> Result {
+ if base_filename.contains('/') || base_filename.contains('\\') {
+ return Err("Filename cannot contain path separators".to_string());
+ }
+
+ let initial_path = parent_dir.join(base_filename);
+
+ if !initial_path.exists() {
+ return Ok(base_filename.to_string());
+ }
+
+ let path = std::path::Path::new(base_filename);
+ let (name_without_ext, extension) = if let Some(ext) = path.extension() {
+ let name_without_ext = path
+ .file_stem()
+ .and_then(|s| s.to_str())
+ .unwrap_or(base_filename);
+ let extension = format!(".{}", ext.to_string_lossy());
+ (name_without_ext, extension)
+ } else {
+ (base_filename, String::new())
+ };
+
+ let max_attempts = attempts.get();
+ let mut counter = 1;
+
+ loop {
+ let numbered_filename = if extension.is_empty() {
+ format!("{} ({})", name_without_ext, counter)
+ } else {
+ format!("{} ({}){}", name_without_ext, counter, &extension)
+ };
+
+ let test_path = parent_dir.join(&numbered_filename);
+
+ if !test_path.exists() {
+ return Ok(numbered_filename);
+ }
+
+ counter += 1;
+
+ // prevent infinite loop
+ if counter > max_attempts {
+ return Err(
+ "Too many filename conflicts, unable to create unique filename".to_string(),
+ );
+ }
+ }
+}
+
+/// Converts moment-style template format strings to chrono format strings.
+///
+/// This function translates a custom subset of date/time patterns to chrono format specifiers.
+///
+/// **Note**: This is NOT fully compatible with moment.js. Notably, `DDD`/`DDDD` map to
+/// weekday names here, whereas in moment.js they represent day-of-year. Day-of-year and
+/// ISO week tokens are not supported.
+///
+/// # Supported Format Patterns
+///
+/// ## Year
+/// - `YYYY` → `%Y` - Year with century (e.g., 2025)
+/// - `YY` → `%y` - Year without century (e.g., 25)
+///
+/// ## Month
+/// - `MMMM` → `%B` - Full month name (e.g., January)
+/// - `MMM` → `%b` - Abbreviated month name (e.g., Jan)
+/// - `MM` → `%m` - Month as zero-padded number (01-12)
+/// - `M` → `%-m` - Month as number (1-12, no padding)
+///
+/// ## Day
+/// - `DDDD` → `%A` - Full weekday name (e.g., Monday)
+/// - `DDD` → `%a` - Abbreviated weekday name (e.g., Mon)
+/// - `DD` → `%d` - Day of month as zero-padded number (01-31)
+/// - `D` → `%-d` - Day of month as number (1-31, no padding)
+///
+/// ## Hour
+/// - `HH` → `%H` - Hour (24-hour) as zero-padded number (00-23)
+/// - `H` → `%-H` - Hour (24-hour) as number (0-23, no padding)
+/// - `hh` → `%I` - Hour (12-hour) as zero-padded number (01-12)
+/// - `h` → `%-I` - Hour (12-hour) as number (1-12, no padding)
+///
+/// ## Minute
+/// - `mm` → `%M` - Minute as zero-padded number (00-59)
+/// - `m` → `%-M` - Minute as number (0-59, no padding)
+///
+/// ## Second
+/// - `ss` → `%S` - Second as zero-padded number (00-59)
+/// - `s` → `%-S` - Second as number (0-59, no padding)
+///
+/// ## AM/PM
+/// - `A` → `%p` - AM/PM (uppercase)
+/// - `a` → `%P` - am/pm (lowercase)
+///
+/// ## Examples
+///
+/// ```
+/// // Basic formats
+/// YYYY-MM-DD HH:mm → %Y-%m-%d %H:%M
+/// // Output: "2025-01-15 14:30"
+///
+/// // Full month and day names
+/// MMMM DD, YYYY → %B %d, %Y
+/// // Output: "January 15, 2025"
+///
+/// // Abbreviated names
+/// DDD, MMM D, YYYY → %a, %b %-d, %Y
+/// // Output: "Mon, Jan 15, 2025"
+///
+/// // Compact format
+/// YYYYMMDD_HHmmss → %Y%m%d_%H%M%S
+/// // Output: "20250115_143045"
+///
+/// // 12-hour format with full names
+/// DDDD, MMMM DD at h:mm A → %A, %B %d at %-I:%M %p
+/// // Output: "Monday, January 15 at 2:30 PM"
+/// ```
+///
+/// # Note
+///
+/// Pattern matching is case-sensitive and processes longer patterns first to avoid
+/// conflicts (e.g., `MMMM` is matched before `MM`).
+pub fn moment_format_to_chrono(template_format: &str) -> Cow<'_, str> {
+ static AC: LazyLock = LazyLock::new(|| {
+ AhoCorasickBuilder::new()
+ // Use LeftmostLongest patterns to ensure overlapping shorter patterns won't also match.
+ .match_kind(MatchKind::LeftmostLongest)
+ .build([
+ "MMMM", "MMM", "MM", "M", "DDDD", "DDD", "DD", "D", "YYYY", "YY", "HH", "H", "hh",
+ "h", "mm", "m", "ss", "s", "A", "a",
+ ])
+ .expect("Failed to build AhoCorasick automaton")
+ });
+
+ if !AC.is_match(template_format) {
+ return Cow::Borrowed(template_format);
+ }
+
+ let replacements = [
+ "%B", "%b", "%m", "%-m", // Month
+ "%A", "%a", "%d", "%-d", // Day
+ "%Y", "%y", // Year
+ "%H", "%-H", // Hour (24)
+ "%I", "%-I", // Hour (12)
+ "%M", "%-M", // Minute
+ "%S", "%-S", // Second
+ "%p", "%P", // AM/PM
+ ];
+
+ let replaced = AC
+ .try_replace_all(template_format, &replacements)
+ .expect("AhoCorasick replace should never fail with default configuration");
+
+ Cow::Owned(replaced)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::fs;
+
+ // moment_format_to_chrono tests
+
+ #[test]
+ fn moment_format_converts_all_patterns() {
+ let input = "YYYY-MM-DD HH:mm:ss A a DDDD - DD - MMMM";
+ let out = moment_format_to_chrono(input);
+ let expected = "%Y-%m-%d %H:%M:%S %p %P %A - %d - %B";
+ assert_eq!(out, expected);
+ }
+
+ #[test]
+ fn moment_format_handles_overlapping_patterns() {
+ // MMMM should be matched before MMM, MM, M
+ assert_eq!(moment_format_to_chrono("MMMM"), "%B");
+ assert_eq!(moment_format_to_chrono("MMM"), "%b");
+ assert_eq!(moment_format_to_chrono("MM"), "%m");
+ assert_eq!(moment_format_to_chrono("M"), "%-m");
+
+ // DDDD should be matched before DDD, DD, D
+ assert_eq!(moment_format_to_chrono("DDDD"), "%A");
+ assert_eq!(moment_format_to_chrono("DDD"), "%a");
+ assert_eq!(moment_format_to_chrono("DD"), "%d");
+ assert_eq!(moment_format_to_chrono("D"), "%-d");
+ }
+
+ #[test]
+ fn moment_format_handles_adjacent_tokens() {
+ // No separator between tokens
+ assert_eq!(moment_format_to_chrono("YYYYMMDD"), "%Y%m%d");
+ assert_eq!(moment_format_to_chrono("HHmmss"), "%H%M%S");
+ assert_eq!(
+ moment_format_to_chrono("DDDDMMMMYYYYHHmmss"),
+ "%A%B%Y%H%M%S"
+ );
+ }
+
+ #[test]
+ fn moment_format_handles_12_and_24_hour() {
+ assert_eq!(moment_format_to_chrono("HH:mm"), "%H:%M"); // 24-hour
+ assert_eq!(moment_format_to_chrono("hh:mm A"), "%I:%M %p"); // 12-hour
+ assert_eq!(moment_format_to_chrono("H"), "%-H"); // No padding
+ assert_eq!(moment_format_to_chrono("h"), "%-I"); // No padding
+ }
+
+ #[test]
+ fn moment_format_handles_padding_variants() {
+ // Padded versions
+ assert_eq!(moment_format_to_chrono("DD"), "%d");
+ assert_eq!(moment_format_to_chrono("MM"), "%m");
+ assert_eq!(moment_format_to_chrono("HH"), "%H");
+
+ // Unpadded versions
+ assert_eq!(moment_format_to_chrono("D"), "%-d");
+ assert_eq!(moment_format_to_chrono("M"), "%-m");
+ assert_eq!(moment_format_to_chrono("H"), "%-H");
+ }
+
+ #[test]
+ fn moment_format_empty_string() {
+ let out = moment_format_to_chrono("");
+ match out {
+ Cow::Borrowed(s) => assert_eq!(s, ""),
+ Cow::Owned(_) => panic!("Expected Cow::Borrowed for empty string"),
+ }
+ }
+
+ // ensure_unique_filename tests
+
+ #[test]
+ fn unique_filename_when_no_conflict() {
+ let temp_dir = tempfile::tempdir().unwrap();
+ let result = ensure_unique_filename("test.cap", temp_dir.path()).unwrap();
+ assert_eq!(result, "test.cap");
+ }
+
+ #[test]
+ fn unique_filename_appends_counter_on_conflict() {
+ let temp_dir = tempfile::tempdir().unwrap();
+
+ // Create existing file
+ fs::write(temp_dir.path().join("test.cap"), "").unwrap();
+
+ let result = ensure_unique_filename("test.cap", temp_dir.path()).unwrap();
+ assert_eq!(result, "test (1).cap");
+ }
+
+ #[test]
+ fn unique_filename_increments_counter() {
+ let temp_dir = tempfile::tempdir().unwrap();
+
+ // Create existing files
+ fs::write(temp_dir.path().join("test.cap"), "").unwrap();
+ fs::write(temp_dir.path().join("test (1).cap"), "").unwrap();
+ fs::write(temp_dir.path().join("test (2).cap"), "").unwrap();
+
+ let result = ensure_unique_filename("test.cap", temp_dir.path()).unwrap();
+ assert_eq!(result, "test (3).cap");
+ }
+
+ #[test]
+ fn unique_filename_handles_no_extension() {
+ let temp_dir = tempfile::tempdir().unwrap();
+
+ fs::write(temp_dir.path().join("README"), "").unwrap();
+
+ let result = ensure_unique_filename("README", temp_dir.path()).unwrap();
+ assert_eq!(result, "README (1)");
+ }
+
+ #[test]
+ fn unique_filename_handles_multiple_dots() {
+ let temp_dir = tempfile::tempdir().unwrap();
+
+ fs::write(temp_dir.path().join("archive.tar.gz"), "").unwrap();
+
+ let result = ensure_unique_filename("archive.tar.gz", temp_dir.path()).unwrap();
+ // Only the last extension is considered
+ assert_eq!(result, "archive.tar (1).gz");
+ }
+
+ #[test]
+ fn unique_filename_respects_max_attempts() {
+ let temp_dir = tempfile::tempdir().unwrap();
+
+ // Create base file
+ fs::write(temp_dir.path().join("test.cap"), "").unwrap();
+
+ // Try with only 3 attempts
+ let attempts = NonZero::new(3).unwrap();
+
+ // Create conflicts for attempts 1, 2, 3
+ fs::write(temp_dir.path().join("test (1).cap"), "").unwrap();
+ fs::write(temp_dir.path().join("test (2).cap"), "").unwrap();
+ fs::write(temp_dir.path().join("test (3).cap"), "").unwrap();
+
+ let result = ensure_unique_filename_with_attempts("test.cap", temp_dir.path(), attempts);
+
+ assert!(result.is_err());
+ assert!(result.unwrap_err().contains("Too many filename conflicts"));
+ }
+
+ #[test]
+ fn unique_filename_handles_directories_as_conflicts() {
+ let temp_dir = tempfile::tempdir().unwrap();
+
+ // Create a directory with the target name
+ fs::create_dir(temp_dir.path().join("test.cap")).unwrap();
+
+ let result = ensure_unique_filename("test.cap", temp_dir.path()).unwrap();
+ assert_eq!(result, "test (1).cap");
+ }
+
+ #[test]
+ fn unique_filename_handles_special_characters() {
+ let temp_dir = tempfile::tempdir().unwrap();
+
+ fs::write(temp_dir.path().join("My Recording (2024).cap"), "").unwrap();
+
+ let result = ensure_unique_filename("My Recording (2024).cap", temp_dir.path()).unwrap();
+ assert_eq!(result, "My Recording (2024) (1).cap");
+ }
+
+ #[test]
+ fn unique_filename_handles_spaces() {
+ let temp_dir = tempfile::tempdir().unwrap();
+
+ fs::write(temp_dir.path().join("My Project.cap"), "").unwrap();
+
+ let result = ensure_unique_filename("My Project.cap", temp_dir.path()).unwrap();
+ assert_eq!(result, "My Project (1).cap");
+ }
+
+ #[test]
+ fn unique_filename_finds_gap_in_sequence() {
+ let temp_dir = tempfile::tempdir().unwrap();
+
+ // Create files with a gap in numbering
+ fs::write(temp_dir.path().join("test.cap"), "").unwrap();
+ fs::write(temp_dir.path().join("test (1).cap"), "").unwrap();
+ // Gap: test (2).cap doesn't exist
+ fs::write(temp_dir.path().join("test (3).cap"), "").unwrap();
+
+ let result = ensure_unique_filename("test.cap", temp_dir.path()).unwrap();
+ // Should find the gap at (2)
+ assert_eq!(result, "test (2).cap");
+ }
+}
diff --git a/packages/ui-solid/src/auto-imports.d.ts b/packages/ui-solid/src/auto-imports.d.ts
index 7bb78817bb..72d8567c5f 100644
--- a/packages/ui-solid/src/auto-imports.d.ts
+++ b/packages/ui-solid/src/auto-imports.d.ts
@@ -8,6 +8,7 @@ export {}
declare global {
const IconCapArrows: typeof import('~icons/cap/arrows.jsx')['default']
const IconCapAudioOn: typeof import('~icons/cap/audio-on.jsx')['default']
+ const IconCapAuto: typeof import('~icons/cap/auto.jsx')['default']
const IconCapBgBlur: typeof import('~icons/cap/bg-blur.jsx')['default']
const IconCapCamera: typeof import('~icons/cap/camera.jsx')['default']
const IconCapCaptions: typeof import('~icons/cap/captions.jsx')['default']
diff --git a/packages/ui-solid/tailwind.config.js b/packages/ui-solid/tailwind.config.js
index 77adf26586..094a6bad66 100644
--- a/packages/ui-solid/tailwind.config.js
+++ b/packages/ui-solid/tailwind.config.js
@@ -89,12 +89,18 @@ module.exports = {
},
keyframes: {
"collapsible-down": {
- from: { height: 0 },
- to: { height: "var(--kb-collapsible-content-height)" },
+ from: { height: 0, filter: "blur(4px)" },
+ to: {
+ height: "var(--kb-collapsible-content-height)",
+ filter: "blur(0px)",
+ },
},
"collapsible-up": {
- from: { height: "var(--kb-collapsible-content-height)" },
- to: { height: 0 },
+ from: {
+ height: "var(--kb-collapsible-content-height)",
+ filter: "blur(0px)",
+ },
+ to: { height: 0, filter: "blur(4px)" },
},
},
animation: {