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
48 changes: 36 additions & 12 deletions README.md

Large diffs are not rendered by default.

85 changes: 39 additions & 46 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ The application must not:
- Add accounts or cloud services.
- Add a clipboard-history browser.
- Retain more than the two values needed for the comparison.
- Add settings or onboarding beyond the external-viewer executable path, warning acknowledgement, and global keyboard shortcut, or add a complex preferences interface.
- Add settings or onboarding beyond the external-viewer executable path, warning acknowledgement, global keyboard shortcut, and start-at-login prompt, or add a complex preferences interface.
- Add document-management features or file access beyond the copied-file conversion and explicit Explorer actions in section 6.4.
- Use a terminal window or embedded web UI for the diff.
- Require an installer for the initial personal release.
Expand Down Expand Up @@ -147,7 +147,7 @@ When ClipDiff starts:

This avoids unexpectedly importing a value that predates application startup.

Consequently, the user must copy two values after ClipDiff starts. Running ClipDiff at sign-in can be documented as an optional setup step, but no startup-settings UI is required.
Consequently, the user must copy two values after ClipDiff starts. Offer the one-time start-at-login prompt and tray toggle described in section 24.

### 6.2 Accepted text

Expand Down Expand Up @@ -215,33 +215,18 @@ If the clipboard is explicitly empty, or contains an empty Unicode text value:
- Apply the recent-clear privacy heuristic if appropriate.
- Otherwise leave existing history unchanged.

### 6.6 Monitoring pause
### 6.6 Continuous monitoring

The notification-area menu must contain a checked **Monitor Clipboard** item.
Monitoring stays active for the application lifetime. Do not expose a monitoring
pause toggle in the notification-area menu. Quit ClipDiff stops monitoring.
Internal pause/resume policy and its tests may remain for native lifecycle use.

When monitoring is disabled:
### 6.7 Captured text lifetime

- Leave existing entries untouched.
- Ignore incoming clipboard changes.
- Show status **Monitoring paused**.

When monitoring is re-enabled:

- Record the clipboard’s current sequence number.
- Do not capture values copied while monitoring was paused.
- Resume from the next clipboard change.

### 6.7 Clear Captured Text

The **Clear Captured Text** command must:

- Remove both in-memory entries.
- Remove the active diff document.
- Return the status to **Waiting for copied text**.
- Not clear or alter the Windows clipboard.
- Not close the application.

Disable the command when there are no entries.
Do not expose a Clear Captured Text command in either the tray or built-in viewer.
Quitting discards the two-entry history and active diff without changing the
Windows clipboard. Keep automatic recent-clear privacy handling and shutdown
cleanup intact.

## 7. Sensitive clipboard content

Expand Down Expand Up @@ -768,8 +753,7 @@ Previous: <preview> [disabled]
Show Diff (Ctrl+Alt+D)
Diff viewer: Built-in [submenu]
Keyboard shortcut...
Monitor Clipboard [checked/unchecked]
Clear Captured Text
Start at login [checked/unchecked]
──────────────────────────────────
About ClipDiff
Quit ClipDiff
Expand All @@ -780,8 +764,7 @@ Behaviour:
- **Show Diff** is disabled until two entries are available.
- **Diff viewer** selects the built-in viewer, a detected external program, or an executable chosen by the user.
- **Keyboard shortcut...** opens the shortcut recorder without changing the active shortcut unless Save succeeds.
- **Monitor Clipboard** toggles monitoring.
- **Clear Captured Text** is disabled when history is empty.
- **Start at login** toggles per-user startup registration (section 24).
- **About ClipDiff** opens a reusable native window showing the application icon, the product version and short source commit hash, Stuart Dunkeld, `stuartd.dev`, and the source repository link.
- **Quit ClipDiff** removes the tray icon, unregisters native listeners/hotkeys, clears references to captured text, and shuts down.
- Double-clicking the tray icon may invoke **Show Diff** when available.
Expand Down Expand Up @@ -837,11 +820,10 @@ The top header contains:

- Title: `Clipboard Diff`
- Summary label
- View selector:
- View selector (explicit dark templates keep the selected value and popup items readable in all Windows themes; preserve native keyboard selection and visible focus):
- `Side by Side`
- `Unified`
- **Copy unified diff** button
- **Clear captured text** button

The view defaults to **Side by Side** each application launch. It may remain at the user’s selected mode during the current process lifetime. It does not need to be persisted.

Expand Down Expand Up @@ -921,7 +903,7 @@ After acknowledgement, create a unique per-comparison directory below `%LOCALAPP

Track the process returned by the launch. Attempt to delete the comparison directory after that process exits, allowing a short grace period for handoff; when ClipDiff exits; and on ClipDiff's next startup to remove stale directories. Cleanup is best effort because crashes, power loss, open file handles, and programs that delegate to another process cannot be controlled. Normalize read-only attributes before deletion. Never log a temporary path together with clipboard text.

Persist application preferences in `%LOCALAPPDATA%\ClipDiff\settings.json`. The file may contain only the selected executable path, the one-time-warning acknowledgement, and the global-shortcut modifier/virtual-key values. It must never contain clipboard text, previews, diffs, temporary-file contents, or selected-file paths. Returning to the built-in viewer clears the executable selection but need not reset the acknowledgement.
Persist application preferences in `%LOCALAPPDATA%\ClipDiff\settings.json`. The file may contain only the selected executable path, the one-time-warning acknowledgement, the global-shortcut modifier/virtual-key values, and whether the startup prompt has been shown. It must never contain clipboard text, previews, diffs, temporary-file contents, or selected-file paths. Returning to the built-in viewer clears the executable selection but need not reset the acknowledgement.

## 17. Suggested solution structure

Expand Down Expand Up @@ -1276,8 +1258,6 @@ On Windows:
1. Inspect the clipboard formats and verify exclusion markers.
1. Copy identical text twice and confirm Show Diff reports No differences.
1. Copy an image and confirm history does not change.
1. Pause monitoring, copy text, resume, and confirm paused text was not captured.
1. Clear captured text and confirm the Windows clipboard itself remains unchanged.
1. Close the diff window and confirm ClipDiff remains in the tray.
1. Quit ClipDiff and restart it; confirm no previous text returns.
1. Run a second instance and confirm it exits cleanly.
Expand All @@ -1296,11 +1276,11 @@ On Windows:
1. Confirm the per-comparison directory and its two read-only files are removed after the launched process exits and when ClipDiff exits.
1. Leave a simulated stale comparison directory and confirm the next ClipDiff start removes it.
1. Remove or rename the selected executable and confirm Show Diff falls back to the built-in viewer.
1. Inspect settings.json and confirm it contains only the selected path, acknowledgement, and shortcut codes, never captured text or previews.
1. Inspect settings.json and confirm it contains only the selected path, acknowledgement, shortcut codes, and startup-prompt flag, never captured text or previews.
1. Copy one file, then right-click a second file and choose **Compare with current ClipDiff capture**; confirm the second file becomes current, the copied file becomes previous, and the configured viewer opens immediately without changing the clipboard.
1. Confirm the Explorer command is single-file only, uses the same binary/encoding/fallback rules, disappears after pause, clear, and quit, and is under **Show more options** on Windows 11 when not shown in the primary menu.
1. Confirm the Explorer command is single-file only, uses the same binary/encoding/fallback rules, disappears on quit or when no capture remains, and is under **Show more options** on Windows 11 when not shown in the primary menu.
1. With no captured value required, select exactly two files in Explorer and choose **Compare two selected files with ClipDiff**; confirm the first Explorer-supplied path becomes previous, the second becomes current, and the configured viewer opens immediately without changing the clipboard.
1. Confirm the two-file command uses the same binary/encoding/fallback rules, is hidden for one file, three or more files, folders, and mixed file/folder selections, and rejects invalid invocations without reading files. Confirm it remains available for exactly two files after clearing captured text, disappears while monitoring is paused and on quit, reappears for exactly two files after resuming, and is under **Show more options** on Windows 11 when not shown in the primary menu.
1. Confirm the two-file command uses the same binary/encoding/fallback rules, is hidden for one file, three or more files, folders, and mixed file/folder selections, and rejects invalid invocations without reading files. Confirm it disappears on quit and is under **Show more options** on Windows 11 when not shown in the primary menu.
1. Upgrade from the old static verb, including a simulated abnormal exit, and confirm there is only one two-file command and no obsolete `CommandStateHandler` registration. Remove the native DLL before starting ClipDiff and confirm clipboard/tray workflows remain usable with no broken two-file menu. Restore the DLL and restart before testing the Explorer command again.
1. Compare two files with the same basename from different directories and confirm the tray, built-in views, copied diff, and supported external-viewer titles use the shortest unique path suffixes.
1. Start ClipDiff after a simulated abnormal exit and confirm it removes an owned stale Explorer registration when there is no captured entry.
Expand Down Expand Up @@ -1394,17 +1374,31 @@ dotnet build --configuration Release

The native clipboard, hotkey, tray, and WPF behaviour still require manual Windows testing.

## 24. Optional start-at-sign-in documentation
## 24. Start at login

ClipDiff cannot recover clipboard values copied before it started because clipboard content is not persisted.
On the first normal launch, ask whether ClipDiff should start when the user signs
in to Windows. Remember either answer in `StartupPromptShown` in settings.json.
Existing settings without this field default to false. Do not prompt secondary
instances or Explorer command invocations. An existing ClipDiff Run registration
counts as an opt-in and suppresses the question.

The README may explain how the user can place a shortcut in:
Provide a checked **Start at login** tray-menu item. Store only the quoted
executable path under the current user's Run key, using the value name ClipDiff.
Remove only that value when switched off. No administrator rights are needed.
Refresh registration state when opening the menu. Registry failures must not
crash the app; disable the toggle if reading fails and explain failed changes.
Report settings-save failures so users know the question may recur.

```
shell:startup
```
On normal launch, update an existing registration to the current executable
location, supporting portable updates. Never recreate a missing registration
merely because the prompt was answered before. Quitting must retain registration.
Windows Startup Apps/Task Manager can independently disable the Run entry; do
not override Windows' approval state. The check mark indicates registration.
Manual Startup-folder shortcuts remain user-managed and must be removed when
switching to the built-in option to avoid launching an older copy.

Do not add a **Start with Windows** settings screen in the first version.
Verify opt-in/out, prompt persistence, upgrade path quoting, failures, and actual
sign-out/sign-in behaviour on Windows.

## 25. README content

Expand Down Expand Up @@ -1454,7 +1448,6 @@ The first release is complete when all of these are true:
- Summary counts are correct.
- **Copy unified diff** produces the agreed output.
- Copied diff output is excluded from history/cloud processing and is not recaptured.
- **Clear Captured Text** resets state without changing the Windows clipboard.
- Closing the diff window leaves the tray app running.
- Quitting releases native resources and loses all captured content.
- Core unit tests pass.
Expand Down
4 changes: 4 additions & 0 deletions src/ClipDiff.Windows/App.xaml.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ protected override void OnStartup(StartupEventArgs args)
{
controller.CompareWithCurrent(selectedFilePath);
}
else
{
Dispatcher.InvokeAsync(controller.InitializeStartAtLogin);
}
}

protected override void OnExit(ExitEventArgs args)
Expand Down
118 changes: 86 additions & 32 deletions src/ClipDiff.Windows/AppController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using ClipDiff.Windows.Hotkeys;
using ClipDiff.Windows.Native;
using ClipDiff.Windows.Settings;
using ClipDiff.Windows.Startup;
using ClipDiff.Windows.Tray;
using ClipDiff.Windows.ViewModels;
using ClipDiff.Windows.Views;
Expand Down Expand Up @@ -37,6 +38,7 @@ internal sealed class AppController : IDisposable
private DiffWindow? diffWindow;
private AboutWindow? aboutWindow;
private ShortcutWindow? shortcutWindow;
private readonly StartupRegistration startupRegistration = new();
private bool disposed;

public AppController()
Expand All @@ -53,7 +55,7 @@ public AppController()
trayIcon = new TrayIconController(
externalDiffTools,
GetSelectedExternalDiffTool()?.ExecutablePath);
viewModel = new DiffWindowViewModel(CopyDiff, ClearCapturedText, Recompare);
viewModel = new DiffWindowViewModel(CopyDiff, Recompare);
explorerCommandServer = new ExplorerCommandServer(CompareWithSelectedFileAsync);
explorerDropTargetServer = new ExplorerDropTargetServer(
OnExplorerFilesSelected,
Expand All @@ -64,15 +66,94 @@ public AppController()
hotKey.Pressed += OnHotKeyPressed;
trayIcon.ShowDiffRequested += OnShowDiffRequested;
trayIcon.ShortcutRequested += OnShortcutRequested;
trayIcon.ToggleMonitoringRequested += OnToggleMonitoringRequested;
trayIcon.DiffToolSelected += OnDiffToolSelected;
trayIcon.ChooseDiffToolRequested += OnChooseDiffToolRequested;
trayIcon.ClearRequested += OnClearRequested;
trayIcon.AboutRequested += OnAboutRequested;
trayIcon.QuitRequested += OnQuitRequested;
trayIcon.ToggleStartAtLoginRequested += OnToggleStartAtLoginRequested;
trayIcon.MenuOpening += OnTrayMenuOpening;
UpdatePresentation();
}

public void InitializeStartAtLogin()
{
if (disposed)
{
return;
}

// Follow a portable upgrade to a new directory only when already opted in.
if (startupRegistration.TryRead(out var registered) && registered)
{
if (!startupRegistration.TrySetEnabled(true))
{
ShowStartupError("ClipDiff could not update its login registration to this location.");
}

RememberStartupPrompt();
}
else if (!settings.StartupPromptShown)
{
var answer = System.Windows.MessageBox.Show(
"Start ClipDiff automatically when you sign in to Windows?\n\n" +
"You can change this later using Start at login in the tray menu.",
"Start ClipDiff at login",
MessageBoxButton.YesNo,
MessageBoxImage.Question,
MessageBoxResult.No);

if (answer == MessageBoxResult.Yes && !startupRegistration.TrySetEnabled(true))
{
ShowStartupError("ClipDiff could not enable start at login. You can try again from the tray menu.");
}

RememberStartupPrompt();
}

RefreshStartAtLogin();
}

private void RememberStartupPrompt()
{
if (settings.StartupPromptShown)
{
return;
}

settings = settings with { StartupPromptShown = true };

if (!settingsStore.TrySave(settings))
{
ShowStartupError("ClipDiff could not save your answer. The startup question may appear next time.");
}
}

private void OnTrayMenuOpening(object? sender, EventArgs args) => RefreshStartAtLogin();

private void RefreshStartAtLogin()
{
var available = startupRegistration.TryRead(out var registered);
trayIcon.SetStartAtLogin(registered, available);
}

private void OnToggleStartAtLoginRequested(object? sender, EventArgs args)
{
if (!startupRegistration.TryRead(out var registered) ||
!startupRegistration.TrySetEnabled(!registered))
{
ShowStartupError("ClipDiff could not change start at login.");
}
else
{
RememberStartupPrompt();
}

RefreshStartAtLogin();
}

private static void ShowStartupError(string message) =>
System.Windows.MessageBox.Show(message, "ClipDiff", MessageBoxButton.OK, MessageBoxImage.Warning);

public void CompareWithCurrent(string selectedFilePath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(selectedFilePath);
Expand All @@ -97,12 +178,12 @@ public void Dispose()
hotKey.Pressed -= OnHotKeyPressed;
trayIcon.ShowDiffRequested -= OnShowDiffRequested;
trayIcon.ShortcutRequested -= OnShortcutRequested;
trayIcon.ToggleMonitoringRequested -= OnToggleMonitoringRequested;
trayIcon.DiffToolSelected -= OnDiffToolSelected;
trayIcon.ChooseDiffToolRequested -= OnChooseDiffToolRequested;
trayIcon.ClearRequested -= OnClearRequested;
trayIcon.AboutRequested -= OnAboutRequested;
trayIcon.QuitRequested -= OnQuitRequested;
trayIcon.ToggleStartAtLoginRequested -= OnToggleStartAtLoginRequested;
trayIcon.MenuOpening -= OnTrayMenuOpening;

explorerContextMenuRegistration.Dispose();
explorerDropTargetServer.Dispose();
Expand Down Expand Up @@ -161,23 +242,6 @@ private void OnHotKeyPressed(object? sender, EventArgs args)

private void OnShowDiffRequested(object? sender, EventArgs args) => ShowDiff();

private void OnToggleMonitoringRequested(object? sender, EventArgs args)
{
if (history.IsMonitoring)
{
clipboardMonitor.Pause();
history.Pause();
}
else
{
history.Resume(clipboardMonitor.Resume());
}

UpdatePresentation();
}

private void OnClearRequested(object? sender, EventArgs args) => ClearCapturedText();

private void OnAboutRequested(object? sender, EventArgs args)
{
aboutWindow ??= new AboutWindow();
Expand Down Expand Up @@ -541,14 +605,6 @@ private void CopyDiff()
UpdatePresentation();
}

private void ClearCapturedText()
{
ClearComparison();
history.Clear();
viewModel.ClearDocument();
UpdatePresentation();
}

private void UpdatePresentation()
{
var status = clipboardMonitor.IsRegistered || !history.IsMonitoring
Expand All @@ -558,10 +614,8 @@ private void UpdatePresentation()
status,
hotKey.IsRegistered,
hotKey.Gesture.DisplayText,
history.IsMonitoring,
history.Current,
history.Previous);
viewModel.SetCanClear(history.Current is not null);
explorerContextMenuRegistration.SetState(
history.IsMonitoring,
history.Current is not null,
Expand Down
3 changes: 2 additions & 1 deletion src/ClipDiff.Windows/Settings/ClipDiffSettingsStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ namespace ClipDiff.Windows.Settings;
internal sealed record ClipDiffSettings(
string? SelectedExecutablePath = null,
bool PlaintextWarningAcknowledged = false,
HotKeyGesture? HotKey = null);
HotKeyGesture? HotKey = null,
bool StartupPromptShown = false);

internal sealed class ClipDiffSettingsStore
{
Expand Down
Loading