feat: unified entry slideover + custom icons + UX fixes

Unified create/edit slideover
- openSlideOver(id) now accepts null for new entries. Same UI
  (icon, name, site, user, password, TOTP, folder, tags) for both
  create and edit. Drops the separate entry modal — no more "save
  first, then add TOTP" two-step.
- "+ New" button, Ctrl+K → New entry, and Ctrl+Shift+A all route
  through the slideover. Ctrl+Shift+A pre-fills the title with the
  foreground window's name.
- Save button visible from the start in new mode (no dirty wait).
- Title shows mode unambiguously: cyan "+ New entry" vs
  "Edit · <name>".

Custom icon upload (soIconField)
- 56×56 preview at the top of every slideover + Upload icon /
  Remove buttons. Same POST /entries/{id}/icon endpoint as the
  auto-fetch path. Validates type / size (64 KB cap matching server).
- Solves the case where DDG doesn't index a domain (self-hosted
  apps, private sites): the user pastes any image and it sticks.

Favicon: privacy-first, DDG only
- Removed the direct-fetch fallback steps (3-5). Privacy stance:
  zero DNS leak outside icons.duckduckgo.com. Domains DDG doesn't
  cover stay icon-less until the user uploads a custom one.
- PM.Favicon.FetchFaviconDataUri takes an optional TFaviconLog
  callback so UMainForm can stream per-step trace into LogLine for
  diagnostics.

Fixes
- Slideover z-index 30 → 50. The topbar's backdrop-filter creates a
  stacking context at z-index 40 which was clipping the slideover
  header (title + close button hidden behind topbar).
- RestoreFromTray no longer un-maximises a maximised window when
  called outside a tray-restore context (Ctrl+Shift+A, Ctrl+Shift+L
  picker, app/focus cmd). SW_RESTORE on a maximised window reverts
  to normal — now we only SW_RESTORE if IsIconic.
- "Show all"/"Show less" per-category state survives renderGrid
  re-renders (healthExpanded map).
- "+ New" and dashboard "Fix" buttons stopPropagation so the
  document-level click-outside handler doesn't close the slideover
  they just opened.
- soDirtyCheck keeps Save visible while in new mode regardless of
  diff.
- openSlideover → openSlideOver typo fix across all call sites.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 00:15:20 +01:00
parent ad5fb21a18
commit 2ef636ce30
8 changed files with 450 additions and 107 deletions
+9 -3
View File
@@ -586,8 +586,11 @@ begin
FMainForm.Show;
// Restore to the exact pre-tray state (maximised/normal + size + pos).
// Falls back to SW_RESTORE if we never captured a placement (e.g. tray
// restore was triggered without a prior MinimizeToTray call).
// Falls back to SW_SHOW (+ conditional SW_RESTORE) if we never captured
// a placement (e.g. focus-app / new-entry hotkey on an already-visible
// window). Unconditional SW_RESTORE would un-maximise a maximised
// window — surprising for the user who pressed Ctrl+Shift+A / +L /
// clicked the tray.
if FHasSavedPlacement then
begin
// showCmd governs whether the window comes back maximised or normal;
@@ -599,7 +602,10 @@ begin
else
begin
ShowWindow(LFormHwnd, SW_SHOW);
ShowWindow(LFormHwnd, SW_RESTORE);
// Only un-iconify if actually minimised. Win32 SW_RESTORE on a
// maximised window reverts it to normal — not what we want here.
if IsIconic(LFormHwnd) then
ShowWindow(LFormHwnd, SW_RESTORE);
end;
SetForegroundWindow(LFormHwnd);
end;
+127 -26
View File
@@ -21,9 +21,15 @@ unit PM.Favicon;
interface
type
TFaviconLog = reference to procedure(const ALine: string);
// Fetches an icon for AHost (bare hostname, no scheme). Returns a
// "data:image/...;base64,..." string on success, or '' on any failure.
function FetchFaviconDataUri(const AHost: string): string;
// ALog (optional): called for each fallback step so the host can trace
// exactly which URL hit / missed.
function FetchFaviconDataUri(const AHost: string;
ALog: TFaviconLog = nil): string;
implementation
@@ -32,9 +38,16 @@ uses
System.Net.HttpClient, System.Net.URLClient;
const
ICON_URL_TEMPLATE = 'https://icons.duckduckgo.com/ip3/%s.ico';
MAX_ICON_BYTES = 65536; // 64 KB cap (matches handler's SetEntryIcon limit)
HTTP_TIMEOUT_MS = 5000;
ICON_URL_TEMPLATE = 'https://icons.duckduckgo.com/ip3/%s.ico';
MAX_ICON_BYTES = 65536; // 64 KB cap (matches handler's SetEntryIcon limit)
HTTP_TIMEOUT_MS = 5000;
// DDG returns a generic placeholder for unknown domains. Bigger threshold
// than 100 to avoid treating its blank globe glyph as a real icon.
MIN_REAL_ICON_BYTES = 500;
// Privacy stance: DDG-only fetches. We don't fall back to the site's
// own /favicon.ico because that would leak DNS to every domain stored
// in the vault. For sites DDG doesn't index, the user can upload a
// custom icon via the slideover (soIconField).
function NormalizeHost(const ARaw: string): string;
var
@@ -96,23 +109,120 @@ begin
Exit('image/x-icon');
end;
function FetchFaviconDataUri(const AHost: string): string;
// "chat.deepseek.com" → "deepseek.com". Returns '' if S has no dot or
// is already a 2-label hostname (we'd fall back to the same input).
function ExtractSLD(const S: string): string;
var
LHost, LUrl, LMime, LBase64: string;
LHttp: THTTPClient;
LResp: IHTTPResponse;
LStream: TMemoryStream;
DotCount, FirstDot: Integer;
I: Integer;
begin
Result := '';
DotCount := 0;
FirstDot := 0;
for I := 1 to Length(S) do
if S[I] = '.' then
begin
Inc(DotCount);
if FirstDot = 0 then FirstDot := I;
end;
if DotCount < 2 then Exit; // already SLD or no dots
Result := Copy(S, FirstDot + 1, MaxInt);
end;
function FetchOneIcon(const AUrl: string;
out ABytes: TBytes): Boolean; forward;
function FetchFaviconDataUri(const AHost: string;
ALog: TFaviconLog = nil): string;
procedure Trace(const ALine: string);
begin
if Assigned(ALog) then ALog(ALine);
end;
var
LHost, LSld, LMime, LBase64, LUrl: string;
LBytes: TBytes;
LOk: Boolean;
begin
Result := '';
LHost := NormalizeHost(AHost);
if LHost = '' then Exit;
if LHost = '' then
begin
Trace('reject: "' + AHost + '" not a valid hostname');
Exit;
end;
// Strategy: prefer DDG (privacy-centralising) but fall back to the
// site's own /favicon.ico for domains DDG doesn't index (self-hosted
// tools, niche services, fresh subdomains, etc.). The user already
// opted into "fetch icons" so the DNS leak to one extra host they
// already visit is an acceptable trade-off for actually getting an icon.
LSld := ExtractSLD(LHost);
LOk := False;
// 1) DDG full host.
LUrl := Format(ICON_URL_TEMPLATE, [LHost]);
if FetchOneIcon(LUrl, LBytes) then
begin
if Length(LBytes) >= MIN_REAL_ICON_BYTES then
begin
LOk := True;
Trace(Format('OK step1 DDG host: %s (%d bytes)', [LUrl, Length(LBytes)]));
end
else
Trace(Format('skip step1 DDG host: %s only %d bytes (< %d)',
[LUrl, Length(LBytes), MIN_REAL_ICON_BYTES]));
end
else
Trace('fail step1 DDG host: ' + LUrl);
// 2) DDG SLD (e.g. "deepseek.com" when "chat.deepseek.com" 404s).
if (not LOk) and (LSld <> '') then
begin
var LTry: TBytes;
LUrl := Format(ICON_URL_TEMPLATE, [LSld]);
if FetchOneIcon(LUrl, LTry) then
begin
if Length(LTry) >= MIN_REAL_ICON_BYTES then
begin
LBytes := LTry; LOk := True;
Trace(Format('OK step2 DDG sld: %s (%d bytes)', [LUrl, Length(LTry)]));
end
else
Trace(Format('skip step2 DDG sld: %s only %d bytes', [LUrl, Length(LTry)]));
end
else
Trace('fail step2 DDG sld: ' + LUrl);
end;
if (not LOk) or (Length(LBytes) = 0) then
begin
Trace('DDG has no icon for ' + LHost + ' — user can upload a custom one');
Exit;
end;
LMime := GuessMimeFromBytes(LBytes);
LBase64 := TNetEncoding.Base64.EncodeBytesToString(LBytes);
LBase64 := StringReplace(LBase64, #13, '', [rfReplaceAll]);
LBase64 := StringReplace(LBase64, #10, '', [rfReplaceAll]);
Result := 'data:' + LMime + ';base64,' + LBase64;
end;
// Low-level HTTP GET. Returns False on any failure (DNS, TLS, non-200,
// oversize). On success ABytes contains the raw image bytes.
// THTTPClient wraps WinHTTP on Windows → native TLS, system cert store,
// zero extra DLLs to ship next to the exe.
function FetchOneIcon(const AUrl: string; out ABytes: TBytes): Boolean;
var
LHttp: THTTPClient;
LResp: IHTTPResponse;
LStream: TMemoryStream;
begin
Result := False;
SetLength(ABytes, 0);
// THTTPClient wraps WinHTTP on Windows → native TLS, system cert store,
// zero extra DLLs to ship next to the exe (unlike Indy + OpenSSL which
// fails silently when libcrypto/libssl are missing).
LHttp := THTTPClient.Create;
LStream := TMemoryStream.Create;
try
@@ -125,9 +235,8 @@ begin
'image/png,image/x-icon,image/*;q=0.8,*/*;q=0.1');
try
LResp := LHttp.Get(LUrl, LStream);
LResp := LHttp.Get(AUrl, LStream);
except
// Any DNS / connect / TLS failure → silent ''.
Exit;
end;
@@ -136,17 +245,9 @@ begin
if LStream.Size > MAX_ICON_BYTES then Exit;
LStream.Position := 0;
SetLength(LBytes, LStream.Size);
LStream.ReadBuffer(LBytes[0], LStream.Size);
LMime := GuessMimeFromBytes(LBytes);
LBase64 := TNetEncoding.Base64.EncodeBytesToString(LBytes);
// Strip CR/LF that the encoder inserts every 76 chars — invalid inside
// an <img src="..."> attribute and bloats the cached blob.
LBase64 := StringReplace(LBase64, #13, '', [rfReplaceAll]);
LBase64 := StringReplace(LBase64, #10, '', [rfReplaceAll]);
Result := 'data:' + LMime + ';base64,' + LBase64;
SetLength(ABytes, LStream.Size);
LStream.ReadBuffer(ABytes[0], LStream.Size);
Result := True;
finally
LStream.Free;
LHttp.Free;