Skip to content
Merged
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
30 changes: 30 additions & 0 deletions design/mockups/drop-safety-review/01-minimal-light.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<!doctype html>
<meta charset="utf-8">
<title>Drop safety review - Minimal light</title>
<style>
:root { --bg:#f7f8fb; --panel:#ffffff; --ink:#1f2937; --muted:#667085; --line:#d9dee7; --ok:#159947; --warn:#b45309; --blue:#2563eb; }
* { box-sizing:border-box; }
body { margin:0; min-height:100vh; display:grid; place-items:center; background:var(--bg); color:var(--ink); font-family:"Segoe UI", system-ui, sans-serif; }
.stage { width:980px; height:620px; position:relative; border:1px solid var(--line); background:linear-gradient(180deg,#fff,#f2f4f8); overflow:hidden; }
.orb { position:absolute; left:454px; top:236px; width:72px; height:72px; border-radius:50%; background:var(--blue); color:#fff; display:grid; place-items:center; font-weight:700; box-shadow:0 12px 30px rgba(37,99,235,.24); }
.bubble { position:absolute; width:132px; padding:12px; border:1px solid var(--line); background:var(--panel); border-radius:8px; box-shadow:0 10px 24px rgba(15,23,42,.08); }
.bubble strong { display:block; font-size:14px; }
.bubble span { color:var(--muted); font-size:12px; }
.docs { left:252px; top:132px; } .images { left:596px; top:132px; } .archive { left:245px; top:390px; } .scripts { left:604px; top:390px; }
.drop { outline:3px solid rgba(21,153,71,.22); border-color:var(--ok); }
.badge { position:absolute; right:10px; top:10px; color:var(--ok); font-weight:700; }
.toast { position:absolute; left:300px; right:300px; bottom:42px; padding:14px 16px; background:#fff7ed; border:1px solid #fed7aa; border-radius:8px; color:#7c2d12; box-shadow:0 14px 26px rgba(124,45,18,.12); }
.toast b { display:block; margin-bottom:4px; }
.panel { position:absolute; left:28px; top:28px; width:240px; padding:14px; background:var(--panel); border:1px solid var(--line); border-radius:8px; }
.panel h1 { margin:0 0 8px; font-size:17px; }
.panel p { margin:0; color:var(--muted); font-size:13px; line-height:1.4; }
</style>
<div class="stage">
<div class="panel"><h1>Drop safety state</h1><p>Conflict-aware copy and move operations keep Undo honest when a destination already existed.</p></div>
<div class="bubble docs drop"><strong>Documents</strong><span>copy target</span><span class="badge">+</span></div>
<div class="bubble images"><strong>Images</strong><span>sort by rule</span></div>
<div class="orb">DW</div>
<div class="bubble archive"><strong>Archive</strong><span>move target</span></div>
<div class="bubble scripts"><strong>Scripts</strong><span>open with</span></div>
<div class="toast"><b>Could not undo completely</b>report.txt existed before this drop; the original file was left protected.</div>
</div>
35 changes: 35 additions & 0 deletions design/mockups/drop-safety-review/02-dark-pro.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<!doctype html>
<meta charset="utf-8">
<title>Drop safety review - Dark pro</title>
<style>
:root { --bg:#111318; --panel:#1c2028; --ink:#eef2f7; --muted:#a3adbd; --line:#343b49; --green:#35d07f; --amber:#f59e0b; --cyan:#5eead4; }
* { box-sizing:border-box; }
body { margin:0; min-height:100vh; display:grid; place-items:center; background:#08090c; color:var(--ink); font-family:"Segoe UI", system-ui, sans-serif; }
.stage { width:980px; height:620px; position:relative; background:radial-gradient(circle at 50% 42%,#222834,#111318 58%); overflow:hidden; border:1px solid #252b36; }
.rail { position:absolute; left:30px; top:30px; bottom:30px; width:250px; padding:16px; background:rgba(28,32,40,.92); border:1px solid var(--line); border-radius:8px; }
.rail h1 { font-size:16px; margin:0 0 16px; }
.row { display:flex; justify-content:space-between; gap:12px; padding:10px 0; border-top:1px solid var(--line); color:var(--muted); font-size:13px; }
.row b { color:var(--ink); }
.orb { position:absolute; left:455px; top:248px; width:70px; height:70px; border-radius:50%; background:var(--cyan); color:#06201e; display:grid; place-items:center; font-weight:800; box-shadow:0 0 0 10px rgba(94,234,212,.08),0 18px 44px rgba(0,0,0,.38); }
.target { position:absolute; min-width:140px; padding:13px 14px; background:rgba(28,32,40,.95); border:1px solid var(--line); border-radius:8px; box-shadow:0 14px 32px rgba(0,0,0,.28); }
.target span { display:block; margin-top:4px; color:var(--muted); font-size:12px; }
.t1 { left:410px; top:112px; } .t2 { left:646px; top:252px; border-color:var(--green); } .t3 { left:402px; top:426px; } .t4 { left:170px; top:252px; }
.pill { float:right; color:var(--green); font-weight:800; }
.toast { position:absolute; right:34px; bottom:34px; width:330px; padding:14px 16px; background:#2c2214; border:1px solid #7c520f; border-radius:8px; color:#fed7aa; }
.toast b { color:#fff; display:block; margin-bottom:5px; }
</style>
<div class="stage">
<div class="rail">
<h1>Safety review</h1>
<div class="row"><b>Drop</b><span>Move</span></div>
<div class="row"><b>Destination</b><span>Downloads</span></div>
<div class="row"><b>Conflict</b><span>1 existing</span></div>
<div class="row"><b>Undo result</b><span>Partial</span></div>
</div>
<div class="target t1">Images<span>rule target</span></div>
<div class="target t2">Downloads <em class="pill">active</em><span>report.txt already exists</span></div>
<div class="orb">DW</div>
<div class="target t3">Archive<span>move target</span></div>
<div class="target t4">Scripts<span>open with</span></div>
<div class="toast"><b>Undo needs attention</b>Dropwheel protected the pre-existing destination and rolled back only tracked files.</div>
</div>
27 changes: 27 additions & 0 deletions design/mockups/drop-safety-review/03-high-contrast-a11y.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<!doctype html>
<meta charset="utf-8">
<title>Drop safety review - High contrast</title>
<style>
* { box-sizing:border-box; }
body { margin:0; min-height:100vh; display:grid; place-items:center; background:#000; color:#fff; font-family:Arial, system-ui, sans-serif; }
.stage { width:980px; height:620px; position:relative; background:#000; border:3px solid #fff; overflow:hidden; }
.summary { position:absolute; left:24px; top:24px; width:300px; border:3px solid #fff; padding:16px; background:#000; }
.summary h1 { margin:0 0 12px; font-size:22px; }
.summary p { margin:0; font-size:15px; line-height:1.45; }
.orb { position:absolute; left:445px; top:236px; width:90px; height:90px; border-radius:50%; border:4px solid #ffd400; color:#ffd400; display:grid; place-items:center; font-size:24px; font-weight:900; }
.target { position:absolute; width:170px; min-height:76px; padding:12px; border:3px solid #fff; background:#000; font-size:17px; }
.target span { display:block; margin-top:6px; color:#ffd400; font-size:14px; }
.active { border-color:#00ff66; box-shadow:0 0 0 5px #003d18 inset; }
.copy { left:405px; top:84px; } .move { left:670px; top:250px; } .sort { left:405px; top:460px; } .run { left:142px; top:250px; }
.warning { position:absolute; left:338px; right:24px; bottom:24px; padding:16px; border:3px solid #ffd400; background:#000; color:#ffd400; font-size:18px; }
.warning b { color:#fff; display:block; margin-bottom:6px; }
</style>
<div class="stage">
<div class="summary"><h1>Incomplete undo</h1><p>The status is explicit when a destination file existed before the drop and cannot be safely removed.</p></div>
<div class="target copy active">Documents<span>Copy target</span></div>
<div class="target move">Archive<span>Move target</span></div>
<div class="orb">DW</div>
<div class="target sort">Sorter<span>Rules active</span></div>
<div class="target run">Editor<span>Open with</span></div>
<div class="warning"><b>Could not undo completely</b>report.txt was present before the drop. Dropwheel did not delete that file.</div>
</div>
29 changes: 29 additions & 0 deletions design/mockups/drop-safety-review/04-playful-rounded.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<!doctype html>
<meta charset="utf-8">
<title>Drop safety review - Soft rounded</title>
<style>
:root { --bg:#fff8f2; --panel:#fff; --ink:#30233a; --muted:#7b6889; --pink:#ff6b8a; --mint:#25c2a0; --sun:#f9b44a; }
* { box-sizing:border-box; }
body { margin:0; min-height:100vh; display:grid; place-items:center; background:linear-gradient(135deg,#fff8f2,#eefaf7); color:var(--ink); font-family:"Segoe UI", system-ui, sans-serif; }
.stage { width:980px; height:620px; position:relative; background:rgba(255,255,255,.76); border:1px solid rgba(48,35,58,.14); overflow:hidden; }
.orb { position:absolute; left:440px; top:230px; width:100px; height:100px; border-radius:50%; background:var(--pink); color:#fff; display:grid; place-items:center; font-weight:900; font-size:24px; box-shadow:0 20px 45px rgba(255,107,138,.28); }
.bubble { position:absolute; width:154px; padding:14px; background:var(--panel); border:1px solid rgba(48,35,58,.12); border-radius:18px; box-shadow:0 18px 32px rgba(48,35,58,.12); }
.bubble strong { display:block; }
.bubble span { color:var(--muted); display:block; margin-top:5px; font-size:13px; }
.b1 { left:392px; top:76px; } .b2 { left:658px; top:245px; border-color:var(--mint); } .b3 { left:392px; top:462px; } .b4 { left:130px; top:245px; }
.marker { position:absolute; right:12px; top:12px; background:var(--mint); color:#fff; width:24px; height:24px; border-radius:50%; display:grid; place-items:center; font-weight:900; }
.toast { position:absolute; left:300px; right:300px; bottom:36px; padding:16px; background:#fff4dc; border:1px solid #ffd38a; border-radius:18px; box-shadow:0 18px 36px rgba(249,180,74,.2); }
.toast b { display:block; margin-bottom:5px; }
.note { position:absolute; left:36px; top:34px; width:265px; background:#fff; border-radius:18px; padding:16px; border:1px solid rgba(48,35,58,.12); }
.note h1 { margin:0 0 8px; font-size:18px; }
.note p { margin:0; color:var(--muted); line-height:1.4; }
</style>
<div class="stage">
<div class="note"><h1>Drop with confidence</h1><p>Undo copy/move keeps track of files that existed before the drop and marks the rollback as partial.</p></div>
<div class="bubble b1">Pictures<span>sort photos</span></div>
<div class="bubble b2"><strong>Downloads</strong><span>copy here</span><i class="marker">+</i></div>
<div class="orb">DW</div>
<div class="bubble b3">Archive<span>move here</span></div>
<div class="bubble b4">Editor<span>open with</span></div>
<div class="toast"><b>Could not undo completely</b>One existing destination was protected.</div>
</div>
1 change: 1 addition & 0 deletions design/mockups/drop-safety-review/APPROVED
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
2026-07-09 fix/product-safety-review approved 01-minimal-light.html for drop safety and incomplete undo states
12 changes: 12 additions & 0 deletions design/mockups/drop-safety-review/NOTES.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Mockups - drop-safety-review

DESIGN stage for the GUI feature "drop-safety-review". Rule: >=4 stylistically distinct
mockups plus approval before GUI implementation.

## Variants
- `01-minimal-light.html` - restrained overlay with an incomplete undo toast.
- `02-dark-pro.html` - operational side panel showing destination conflict state.
- `03-high-contrast-a11y.html` - high-contrast rollback status.
- `04-playful-rounded.html` - softer bubble layout with partial undo notification.

Approved direction: `01-minimal-light.html`.
12 changes: 10 additions & 2 deletions src/Dropwheel/Services/FileOps.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,17 @@ private struct SHFILEOPSTRUCT
[DllImport("shell32.dll", CharSet = CharSet.Unicode)]
private static extern int SHFileOperation(ref SHFILEOPSTRUCT op);

public static string[] DestinationConflicts(IEnumerable<string> files, string destFolder)
{
return files
.Select(f => Path.Combine(destFolder, Path.GetFileName(f)))
.Where(p => File.Exists(p) || Directory.Exists(p))
.ToArray();
}

/// <summary>Copy or move files into destFolder. When silent (used by the folder watcher for
/// auto-sort) the shell shows no progress window, no error UI and no conflict prompt — collisions
/// are auto-renamed — so background sorting never interrupts the user with dialogs.</summary>
/// auto-sort) the shell shows no progress window, no error UI and no conflict prompt. Callers
/// that need no-overwrite behavior must preflight with DestinationConflicts first.</summary>
public static bool Execute(IEnumerable<string> files, string destFolder, DropAction action, bool silent = false)
{
var list = files.ToArray();
Expand Down
21 changes: 16 additions & 5 deletions src/Dropwheel/Services/TargetStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ namespace Dropwheel.Services;
public static class TargetStore
{
public static AppConfig Config { get; private set; } = new();
internal static string? DirOverride { get; set; }

/// <summary>Raised after the config is written to disk. The folder watcher listens to this to
/// re-sync its FileSystemWatchers when targets or their Watch flag change.</summary>
Expand All @@ -18,7 +19,7 @@ public static class TargetStore
public static IEnumerable<TargetItem> AllTargets =>
Config.Targets.SelectMany(t => t.IsGroup ? (IEnumerable<TargetItem>)t.Children! : new[] { t });

public static string Dir => Path.Combine(
public static string Dir => DirOverride ?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Dropwheel");
public static string FilePath => Path.Combine(Dir, "config.json");

Expand All @@ -45,7 +46,12 @@ public static void Load()
catch (IOException ex) { ErrorLog.Write("Config is unreadable; backing it up and recreating defaults", ex); shouldBackup = true; }
catch (UnauthorizedAccessException ex) { ErrorLog.Write("Config is unreadable; backing it up and recreating defaults", ex); shouldBackup = true; }
}
if (shouldBackup) BackupBadConfig(DateTime.Now);
if (shouldBackup && !BackupBadConfig(DateTime.Now))
{
Config = Defaults();
ErrorLog.Write("Settings file could not be backed up; using defaults in memory without overwriting it.");
return;
}
Config = Defaults();
Save();
}
Expand All @@ -56,17 +62,22 @@ internal static string BackupPath(DateTime now)
return Path.Combine(Dir, $"config.bad.{stamp}.json");
}

private static void BackupBadConfig(DateTime now)
private static bool BackupBadConfig(DateTime now)
{
try
{
if (!File.Exists(FilePath)) return;
if (!File.Exists(FilePath)) return true;
var backup = BackupPath(now);
for (int i = 2; File.Exists(backup); i++)
backup = Path.Combine(Dir, $"config.bad.{now:yyyyMMdd_HHmmss}.{i}.json");
File.Copy(FilePath, backup);
return true;
}
catch (Exception ex)
{
ErrorLog.Write("Failed to back up bad config", ex);
return false;
}
catch (Exception ex) { ErrorLog.Write("Failed to back up bad config", ex); }
}

/// <summary>Writes via a temp file then renames it: if the process is killed mid-write, the
Expand Down
6 changes: 6 additions & 0 deletions src/Dropwheel/Services/WatcherService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,12 @@ private void SortOne(Entry entry, string file)
// Create the destination folder first: otherwise SHFileOperation moving a single file
// to a non-existent path treats the last segment as a new file name, not a folder.
Directory.CreateDirectory(folder);
var conflicts = FileOps.DestinationConflicts(files, folder);
if (conflicts.Length > 0)
{
ErrorLog.Write($"Auto-sort skipped '{file}' because destination already exists: '{conflicts[0]}'");
continue;
}
if (FileOps.Execute(files, folder, DropAction.Move, silent: true))
_ui.InvokeAsync(() => QueueToast(files.Count)); // coalesce the toast on the UI thread
else
Expand Down
8 changes: 4 additions & 4 deletions src/Dropwheel/UI/OverlayWindow.Dnd.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,9 @@ private void OnBubbleDropCore(TargetItem t, DragEventArgs e)
return;
}
var act = Resolve(t, e);
bool hadCollision = FileOps.HasDestinationCollision(files, dest);
var op = BuildOpBefore(act, files, dest);
bool ok = FileOps.Execute(files, dest, act);
if (ok) RememberOpIfUnambiguous(act, files, dest, hadCollision);
if (ok) RememberOp(op);
ShowToast(ok
? $"{(act == DropAction.Move ? "➜ Moved" : "⧉ Copied")}: {files.Length} item(s) → {t.Name}"
: "Operation was not completed", ok);
Expand All @@ -90,7 +90,7 @@ private void OnBubbleDropCore(TargetItem t, DragEventArgs e)
if (saved.Length > 0)
{
if (t.IsSorter) SortSavedVirtuals(t, saved);
else RememberOpIfUnambiguous(DropAction.Copy, saved, dest, hadCollision: false);
else RememberOp(BuildCreatedCopyOp(saved, dest));
}
ShowToast(saved.Length > 0
? $"⧉ Saved: {saved.Length} item(s) → {t.Name}"
Expand All @@ -102,7 +102,7 @@ private void OnBubbleDropCore(TargetItem t, DragEventArgs e)
if (saved is { } path)
{
if (t.IsSorter) SortSavedVirtuals(t, new[] { path });
else RememberOpIfUnambiguous(DropAction.Copy, new[] { path }, dest, hadCollision: false);
else RememberOp(BuildCreatedCopyOp(new[] { path }, dest));
}
ShowToast(saved != null
? $"≡ Saved text → {System.IO.Path.GetFileName(saved)}"
Expand Down
21 changes: 11 additions & 10 deletions src/Dropwheel/UI/OverlayWindow.Sort.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,16 @@ private void DropSorted(TargetItem t, string[] files, DropAction act)
{
var plan = SortService.Plan(t, files);
bool ok = true;
var ops = new List<(DropAction, string[], string, bool)>();
var ops = new List<FileOp>();
foreach (var (folder, group) in plan)
{
Directory.CreateDirectory(folder);
bool hadCollision = FileOps.HasDestinationCollision(group, folder);
if (FileOps.Execute(group, folder, act)) ops.Add((act, group.ToArray(), folder, hadCollision));
var sources = group.ToArray();
var op = BuildOpBefore(act, sources, folder);
if (FileOps.Execute(sources, folder, act)) ops.Add(op);
else ok = false;
}
if (ops.Count > 0) RememberOpsIfUnambiguous(ops);
if (ops.Count > 0) RememberOps(ops);
ShowToast(ok
? $"⇅ Sorted: {files.Length} item(s) → {t.Name}"
: "Sorting was not completed", ops.Count > 0);
Expand All @@ -31,17 +32,17 @@ private void DropSorted(TargetItem t, string[] files, DropAction act)
private void SortSavedVirtuals(TargetItem t, string[] saved)
{
var plan = SortService.Plan(t, saved);
var ops = new List<(DropAction, string[], string, bool)>();
var ops = new List<FileOp>();
string root = IOPath.GetFullPath(t.Path).TrimEnd('\\');
foreach (var (folder, group) in plan)
{
if (IOPath.GetFullPath(folder).TrimEnd('\\') == root)
{ ops.Add((DropAction.Copy, group.ToArray(), folder, false)); continue; }
{ ops.Add(BuildCreatedCopyOp(group.ToArray(), folder)); continue; }
Directory.CreateDirectory(folder);
bool hadCollision = FileOps.HasDestinationCollision(group, folder);
if (FileOps.Execute(group, folder, DropAction.Move))
ops.Add((DropAction.Copy, group.ToArray(), folder, hadCollision));
var sources = group.ToArray();
if (FileOps.Execute(sources, folder, DropAction.Move))
ops.Add(BuildCreatedCopyOp(sources, folder));
}
if (ops.Count > 0) RememberOpsIfUnambiguous(ops);
if (ops.Count > 0) RememberOps(ops);
}
}
Loading