Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 75 additions & 9 deletions src-tauri/src/file_management.rs
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,8 @@ pub struct AppSettings {
pub waveform_height: Option<u32>,
#[serde(default)]
pub active_waveform_channel: Option<String>,
#[serde(default)]
pub group_preferred_type: Option<String>,
}

fn default_adjustment_visibility() -> HashMap<String, bool> {
Expand Down Expand Up @@ -525,6 +527,7 @@ impl Default for AppSettings {
is_waveform_visible: Some(false),
waveform_height: Some(220),
active_waveform_channel: Some("luma".to_string()),
group_preferred_type: Some("raw".to_string()),
}
}
}
Expand All @@ -538,6 +541,39 @@ pub struct ImageFile {
tags: Option<Vec<String>>,
exif: Option<HashMap<String, String>>,
is_virtual_copy: bool,
is_raw: bool,
group_id: Option<String>,
}

/// Grouping key from a source image path: the full path with the
/// extension stripped. Files sharing this key are variants of the
/// same shot. Case-sensitive.
fn make_group_key(source_path: &Path) -> String {
source_path.with_extension("").to_string_lossy().into_owned()
}

/// Tag files that share a stem with `group_id`. Virtual copies are
/// excluded from counting (one file + its virtual copy don't form a
/// group) but still get assigned the group_id of their source.
fn assign_group_ids(files: &mut Vec<ImageFile>) {
let mut stem_sources: HashMap<String, HashSet<PathBuf>> = HashMap::new();

for file in files.iter() {
if file.is_virtual_copy {
continue;
}
let (source_path, _) = parse_virtual_path(&file.path);
let key = make_group_key(&source_path);
stem_sources.entry(key).or_default().insert(source_path);
}

for file in files.iter_mut() {
let (source_path, _) = parse_virtual_path(&file.path);
let key = make_group_key(&source_path);
if stem_sources.get(&key).map_or(false, |s| s.len() >= 2) {
file.group_id = Some(key);
}
}
}

#[derive(Serialize, Deserialize, Debug, Clone)]
Expand Down Expand Up @@ -967,6 +1003,8 @@ pub fn list_images_in_dir(path: String, app_handle: AppHandle) -> Result<Vec<Ima
tags,
exif: None,
is_virtual_copy,
is_raw: is_raw_file(&path_str),
group_id: None,
rating,
});
}
Expand All @@ -975,6 +1013,7 @@ pub fn list_images_in_dir(path: String, app_handle: AppHandle) -> Result<Vec<Ima
})
.collect();

assign_group_ids(&mut result_list);
Ok(result_list)
}

Expand Down Expand Up @@ -1093,6 +1132,8 @@ pub fn list_images_recursive(
tags,
exif: None,
is_virtual_copy,
is_raw: is_raw_file(&path_str),
group_id: None,
rating,
});
}
Expand All @@ -1101,6 +1142,7 @@ pub fn list_images_recursive(
})
.collect();

assign_group_ids(&mut result_list);
Ok(result_list)
}

Expand Down Expand Up @@ -3310,9 +3352,7 @@ pub fn delete_files_with_associated(paths: Vec<String>) -> Result<(), String> {

for path_str in &paths {
let (source_path, _) = parse_virtual_path(path_str);
if let Some(file_name) = source_path.file_name().and_then(|s| s.to_str())
&& let Some(stem) = file_name.split('.').next()
{
if let Some(stem) = source_path.file_stem().and_then(|s| s.to_str()) {
stems_to_delete.insert(stem.to_string());
}
if let Some(parent) = source_path.parent() {
Expand All @@ -3337,13 +3377,39 @@ pub fn delete_files_with_associated(paths: Vec<String>) -> Result<(), String> {
let entry_filename = entry.file_name();
let entry_filename_str = entry_filename.to_string_lossy();

if let Some(base_stem) = entry_filename_str.split('.').next()
&& stems_to_delete.contains(base_stem)
&& (is_supported_image_file(entry_filename_str.as_ref())
|| entry_filename_str.ends_with(".rrdata")
|| entry_filename_str.ends_with(".rrexif"))
if entry_filename_str.ends_with(".rrdata") {
// Sidecars: {filename}.rrdata or {filename}.{vc_id}.rrdata
// VC ids are first 6 chars of a UUID v4, see create_virtual_copy()
let without_rrdata = entry_filename_str.trim_end_matches(".rrdata");
let image_filename = if let Some(dot_pos) = without_rrdata.rfind('.') {
let suffix = &without_rrdata[dot_pos + 1..];
if suffix.len() == 6 && suffix.chars().all(|c| c.is_ascii_hexdigit()) {
&without_rrdata[..dot_pos]
} else {
without_rrdata
}
} else {
without_rrdata
};
let sidecar_stem = Path::new(image_filename)
.file_stem()
.and_then(|s| s.to_str());
if let Some(stem) = sidecar_stem {
if stems_to_delete.contains(stem) {
files_to_trash.insert(entry_path);
}
}
} else if is_supported_image_file(entry_filename_str.as_ref())
|| entry_filename_str.ends_with(".rrexif")
{
files_to_trash.insert(entry_path);
let entry_stem = Path::new(entry_filename_str.as_ref())
.file_stem()
.and_then(|s| s.to_str());
if let Some(stem) = entry_stem {
if stems_to_delete.contains(stem) {
files_to_trash.insert(entry_path);
}
}
}
}
}
Expand Down
143 changes: 90 additions & 53 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,10 @@ import {
ThumbnailSize,
ThumbnailAspectRatio,
CullingSuggestions,
GroupId,
GroupPreference,
} from './components/ui/AppProperties';
import { buildImageGroups, getVariantLabel, GroupingResult } from './utils/imageGrouping';
import { ChannelConfig } from './components/adjustments/Curves';
import HdrModal from './components/modals/HdrModal';

Expand Down Expand Up @@ -292,6 +295,7 @@ function App() {
rawStatus: RawStatus.All,
});
const [supportedTypes, setSupportedTypes] = useState<SupportedTypes | null>(null);
const [groupPreferredType, setGroupPreferredType] = useState<GroupPreference>('raw');
const [selectedImage, setSelectedImage] = useState<SelectedImage | null>(null);
const selectedImagePathRef = useRef<string | null>(null);
useEffect(() => {
Expand Down Expand Up @@ -1147,49 +1151,56 @@ function App() {
}
};

const sortedImageList = useMemo(() => {
let processedList = imageList;

if (filterCriteria.rawStatus === RawStatus.RawOverNonRaw && supportedTypes) {
const rawBaseNames = new Set<string>();

for (const image of imageList) {
const pathWithoutVC = image.path.split('?vc=')[0];
const filename = pathWithoutVC.split(/[\\/]/).pop() || '';
const lastDotIndex = filename.lastIndexOf('.');
const extension = lastDotIndex !== -1 ? filename.substring(lastDotIndex + 1).toLowerCase() : '';

if (extension && supportedTypes.raw.includes(extension)) {
const baseName = lastDotIndex !== -1 ? filename.substring(0, lastDotIndex) : filename;
const parentDir = getParentDir(pathWithoutVC);
const uniqueKey = `${parentDir}/${baseName}`;
rawBaseNames.add(uniqueKey);
}
}

if (rawBaseNames.size > 0) {
processedList = imageList.filter((image) => {
const pathWithoutVC = image.path.split('?vc=')[0];
const filename = pathWithoutVC.split(/[\\/]/).pop() || '';
const lastDotIndex = filename.lastIndexOf('.');
const extension = lastDotIndex !== -1 ? filename.substring(lastDotIndex + 1).toLowerCase() : '';

const isNonRaw = extension && supportedTypes.nonRaw.includes(extension);

if (isNonRaw) {
const baseName = lastDotIndex !== -1 ? filename.substring(0, lastDotIndex) : filename;
const parentDir = getParentDir(pathWithoutVC);
const uniqueKey = `${parentDir}/${baseName}`;

if (rawBaseNames.has(uniqueKey)) {
return false;
}
}

return true;
});
}
const isGroupingActive = filterCriteria.rawStatus === RawStatus.GroupVariants ||
filterCriteria.rawStatus === RawStatus.RawOverNonRaw; // migration: treat old setting as grouping

const groupingResult: GroupingResult | null = useMemo(() => {
if (!isGroupingActive) return null;
return buildImageGroups(imageList, groupPreferredType);
}, [imageList, isGroupingActive, groupPreferredType]);

// Per-group badge data for thumbnails (count + extension label like "RAF+JPG")
const groupBadgeInfo: Record<GroupId, { count: number; label: string }> = useMemo(() => {
if (!groupingResult) return {};
const info: Record<GroupId, { count: number; label: string }> = {};
for (const [groupId, group] of groupingResult.groups) {
const extensions = [...new Set(group.variants.map((v) => getVariantLabel(v.path)))];
info[groupId] = {
count: group.variants.length,
label: extensions.join('+'),
};
}
return info;
}, [groupingResult]);

// Variant pills for the editor toolbar. Suppressed for VCs since the
// pills point to originals and would silently leave the VC context.
const variantOptions = useMemo(() => {
if (!selectedImage || !groupingResult) return [];
const imageFile = imageList.find((img) => img.path === selectedImage.path);
if (!imageFile?.group_id || imageFile.is_virtual_copy) return [];
const group = groupingResult.groups.get(imageFile.group_id);
if (!group || group.variants.length < 2) return [];
return group.variants.map((v) => ({
path: v.path,
label: getVariantLabel(v.path),
}));
}, [selectedImage?.path, groupingResult, imageList]);

// Highlight the group primary in the filmstrip even when viewing a non-primary variant
const filmstripActivePath = useMemo(() => {
if (!selectedImage) return null;
if (!groupingResult) return selectedImage.path;
const imageFile = imageList.find((img) => img.path === selectedImage.path);
if (!imageFile?.group_id || imageFile.is_virtual_copy) return selectedImage.path;
const group = groupingResult.groups.get(imageFile.group_id);
return group?.primary.path ?? selectedImage.path;
}, [selectedImage?.path, groupingResult, imageList]);

const sortedImageList = useMemo(() => {
const processedList = isGroupingActive && groupingResult
? groupingResult.displayList
: imageList;

const filteredList = processedList.filter((image) => {
if (filterCriteria.rating > 0) {
Expand All @@ -1205,15 +1216,12 @@ function App() {
filterCriteria.rawStatus &&
filterCriteria.rawStatus !== RawStatus.All &&
filterCriteria.rawStatus !== RawStatus.RawOverNonRaw &&
supportedTypes
filterCriteria.rawStatus !== RawStatus.GroupVariants
) {
const extension = image.path.split('.').pop()?.toLowerCase() || '';
const isRaw = supportedTypes.raw?.includes(extension);

if (filterCriteria.rawStatus === RawStatus.RawOnly && !isRaw) {
if (filterCriteria.rawStatus === RawStatus.RawOnly && !image.is_raw) {
return false;
}
if (filterCriteria.rawStatus === RawStatus.NonRawOnly && isRaw) {
if (filterCriteria.rawStatus === RawStatus.NonRawOnly && image.is_raw) {
return false;
}
}
Expand Down Expand Up @@ -1368,7 +1376,7 @@ function App() {
return order === SortDirection.Ascending ? comparison : -comparison;
});
return list;
}, [imageList, sortCriteria, imageRatings, filterCriteria, supportedTypes, searchCriteria, appSettings]);
}, [imageList, sortCriteria, imageRatings, filterCriteria, supportedTypes, searchCriteria, appSettings, isGroupingActive, groupingResult]);

useEffect(() => {
if (selectedImage?.path && selectedImage.isReady && finalPreviewUrl) {
Expand Down Expand Up @@ -1804,6 +1812,9 @@ function App() {
if (settings?.waveformHeight !== undefined) {
setWaveformHeight(settings.waveformHeight);
}
if (settings?.groupPreferredType) {
setGroupPreferredType(settings.groupPreferredType);
}
if (settings?.pinnedFolders && settings.pinnedFolders.length > 0) {
try {
const trees = await invoke(Invokes.GetPinnedFolderTrees, {
Expand Down Expand Up @@ -1931,6 +1942,18 @@ function App() {
}
}, [isWaveformVisible, activeWaveformChannel, waveformHeight, appSettings, handleSettingsChange]);

useEffect(() => {
if (isInitialMount.current || !appSettings) {
return;
}
if (appSettings.groupPreferredType !== groupPreferredType) {
handleSettingsChange({
...appSettings,
groupPreferredType,
});
}
}, [groupPreferredType, appSettings, handleSettingsChange]);

useEffect(() => {
if (!appSettings?.adaptiveEditorTheme || !selectedImage) {
setAdaptivePalette(null);
Expand Down Expand Up @@ -2508,6 +2531,14 @@ function App() {
[selectedImage?.path, debouncedSave, debouncedSetHistory, thumbnails, resetAdjustmentsHistory],
);

const handleVariantSelect = useCallback(
(path: string) => {
if (selectedImage?.path === path) return;
handleImageSelect(path);
},
[handleImageSelect, selectedImage?.path],
);

const executeDelete = useCallback(
async (pathsToDelete: Array<string>, options = { includeAssociated: false }) => {
if (!pathsToDelete || pathsToDelete.length === 0) {
Expand Down Expand Up @@ -4412,10 +4443,9 @@ function App() {
imageList.some((image) => image.path.startsWith(`${finalSelection[0]}?vc=`));

const hasAssociatedFiles = finalSelection.some((selectedPath) => {
const lastDotIndex = selectedPath.lastIndexOf('.');
if (lastDotIndex === -1) return false;
const basePath = selectedPath.substring(0, lastDotIndex);
return imageList.some((image) => image.path.startsWith(basePath + '.') && image.path !== selectedPath);
const image = imageList.find((img) => img.path === selectedPath);
// Rust sets group_id when >= 2 files share a stem, independent of the UI toggle
return image?.group_id != null;
});

let deleteSubmenu;
Expand Down Expand Up @@ -5092,6 +5122,10 @@ function App() {
onNavigateToCommunity={() => setActiveView('community')}
listColumnWidths={listColumnWidths}
setListColumnWidths={setListColumnWidths}
groupVariantCounts={groupVariantCounts}
groupPreferredType={groupPreferredType}
groupBadgeInfo={groupBadgeInfo}
onGroupPreferredTypeChange={setGroupPreferredType}
/>
)}
{rootPath && (
Expand Down Expand Up @@ -5193,6 +5227,8 @@ function App() {
goToAdjustmentsHistoryIndex={goToAdjustmentsHistoryIndex}
liveRotation={liveRotation}
isInstantTransition={isInstantTransition}
variantOptions={variantOptions}
onVariantSelect={handleVariantSelect}
/>
<div
className={clsx(
Expand All @@ -5209,6 +5245,7 @@ function App() {
onMouseDown={createResizeHandler(setBottomPanelHeight, bottomPanelHeight)}
/>
<BottomBar
activeDisplayPath={filmstripActivePath}
filmstripHeight={bottomPanelHeight}
imageList={sortedImageList}
imageRatings={imageRatings}
Expand Down
Loading
Loading