Skip to content

feat: per-connection icon & accent color override (#189)#241

Draft
NewtTheWolf wants to merge 28 commits into
TabularisDB:mainfrom
NewtTheWolf:feat/per-connection-icon-color-189
Draft

feat: per-connection icon & accent color override (#189)#241
NewtTheWolf wants to merge 28 commits into
TabularisDB:mainfrom
NewtTheWolf:feat/per-connection-icon-color-189

Conversation

@NewtTheWolf
Copy link
Copy Markdown
Contributor

Summary

Lets users visually differentiate connections that share the same driver (e.g. "MySQL local" vs "MySQL prod") by picking a per-connection accent color and/or icon. Closes #189.

What's new

  • Accent color: 12-swatch curated palette + custom hex input
  • Icon overrides (mutually exclusive): a curated set of 30 lucide icons, an emoji, or a custom image upload (PNG/JPG/WebP/SVG, max 512 KB)
  • Fallback: when nothing is set, renders the driver's manifest icon/color exactly as before

Architecture

  • New optional field appearance: { icon?, accentColor? } on SavedConnection (Rust + TS mirror). Backwards compatible — existing connections.json deserializes unchanged.
  • Tagged-union IconOverride { Pack { id } | Emoji { value } | Image { path } }.
  • Custom images copied into <app_data>/connection-icons/<id>-<sha8>.<ext> via new Tauri commands save_connection_icon / delete_connection_icon. Validates MIME via magic bytes, rejects SVGs containing <script>/javascript:/on*= event handlers (incl. spaced and chained variants).
  • Cascade delete: removing a connection cleans up its custom icon file. Modal cancel cleans up uploaded-but-unsaved icons.
  • New set_connection_appearance command attaches appearance to a saved connection by id (needed because the backend mints its own id on create).
  • New TS resolvers getConnectionAccent / getConnectionIcon in src/utils/driverUI.tsx. Wired into 4 components: OpenConnectionItem, ConnectionCard, ConnectionListItem, VisualExplainModal. Lucide pack lives in src/utils/connectionIconPack.ts as an explicit 30-icon Record (no import *, so tree-shaking is preserved).
  • New <AppearanceSection> mounted inside NewConnectionModal with collapsible color picker + 4-tab icon picker (Default / Pack / Emoji / Image).
  • i18n keys added to all 7 locales (de/en/es/fr/it/ja/zh).

Out of scope (deferred)

  • Tab-header and status-bar visibility points — those components don't exist in the codebase yet. When introduced they call the new resolvers, no further work needed here.
  • Translated backend error strings (currently raw Rust strings surface on upload failures — a follow-up).

Test plan

  • cd src-tauri && cargo test --lib — 598 passed (10 new module tests + 3 set_appearance tests + 1 update_connection regression test)
  • pnpm vitest run — 2275 passed; 30 pre-existing failures (SettingsProvider/ThemeProvider/useSidebarResize localStorage mock issues) unchanged
  • pnpm tsc --noEmit — clean
  • Manual: create two MySQL connections, set different accent + emoji on each, confirm both render correctly in sidebar + connection picker
  • Manual: upload a 50 KB PNG → renders; delete connection → icon file removed
  • Manual: reject 600 KB image with inline error; reject SVG containing <script>
  • Manual: simulate file deletion outside the app → resolver falls back to driver icon, no crash

Spec & plan

Local-only docs (/docs is gitignored):

  • docs/superpowers/specs/2026-05-21-per-connection-icon-color-design.md
  • docs/superpowers/plans/2026-05-21-per-connection-icon-color.md

…TabularisDB#189)

- Extract curated 30-icon subset into connectionIconPack.ts (src-of-truth for picker + resolver)
- Replace import * as LucideIcons with named pack import to enable tree-shaking
- Reset failed state on path change so ConnectionIconImage recovers from prior failures
- Guard onError against post-unmount setState via mountedRef
- Add aria-hidden to emoji span; narrow getConnectionIcon Pick to "appearance" only
- Add ConnectionIconImage.test.tsx with regression test for sticky-failed fix
- Update global lucide-react mock in tests/setup.ts with pack icons
…#189)

Replace getDriverColor/getDriverIcon with getConnectionAccent/getConnectionIcon
in OpenConnectionItem, ConnectionCard, ConnectionListItem, and VisualExplainModal
so per-connection accent colours and icon overrides are honoured everywhere.
)

Add set_connection_appearance Rust command to patch the stored appearance
field after save. Mount AppearanceSection at the bottom of the General tab,
wired with appearance state pre-populated from initialConnection on edit.
For new connections a stable UUID is generated on mount and used as the icon
upload target; after save the appearance (with its icon path) is persisted
under the real backend-minted id via set_connection_appearance.
…ance (TabularisDB#189)

- Delete previous image when user picks a new one in AppearanceSection (intra-session orphan)
- Delete uploaded-but-unsaved image on modal cancel via useEffect cleanup + wasSavedRef
- Extract set_appearance_impl pure helper; add 3 unit tests (update, clear, missing-id)
…DB#189)

When the Custom hex input is opened after a palette swatch was picked,
the draft now mirrors the current accentColor so closing the field can't
silently revert it. Also drops i18n keys for errors that aren't wired up
yet (tooLarge, invalidFormat) and the preview label that has no UI.
Comment thread src-tauri/src/connection_appearance.rs Outdated
let Some(crate::models::IconOverride::Image { path }) = a.icon.as_ref() else { return Ok(()) };
let full = app_data_dir.join(path);
if full.exists() {
fs::remove_file(&full).map_err(|e| IconError::Io(e.to_string()))?;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CRITICAL: Path traversal vulnerability — cascade_delete_if_image deletes files without verifying the resolved path is inside the app data directory.

A maliciously crafted connections.json with an image path like ../../../sensitive-file.txt would cause arbitrary file deletion when the connection is removed. Unlike delete_connection_icon (which canonicalizes and checks starts_with), this function joins the raw stored path directly and calls fs::remove_file.

Comment thread src-tauri/src/commands.rs Outdated
group_id: original.group_id.clone(), // Copy to same group as original
sort_order: None, // Will be placed at end of group
detect_json_in_text_columns: original.detect_json_in_text_columns,
appearance: original.appearance.clone(),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Duplicating a connection copies the image icon path by reference.

Both the original and the duplicate share the same icon file path. When either connection is deleted, cascade_delete_if_image removes the shared file, breaking the other connection's custom icon. Consider either copying the icon file with a new path on duplicate, or checking whether other connections still reference the path before cascade-deleting.

value.icon?.type === "emoji" ? "emoji" :
value.icon?.type === "image" ? "image" : "default";

const [tab, setTab] = useState<IconTab>(initialTab);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: tab state is initialized from value.icon?.type but never re-syncs when value changes externally.

If the parent resets the appearance (e.g., on modal re-open or form reset), initialTab is recomputed but tab stays on the old selection because it's held in local useState. This causes the UI to show the wrong tab (e.g., "pack" selected while value has no icon). Consider syncing tab with value.icon?.type via useEffect.

sourcePath: picked,
});
if (previousImagePath && previousImagePath !== stored) {
try { await invoke("delete_connection_icon", { relativePath: previousImagePath }); }
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Eager deletion of previous image file can cause data loss on modal cancel.

pickImage deletes the previous image file from disk immediately (before the connection is saved). If the user picks a new image and then cancels the modal, the cleanup effect in NewConnectionModal deletes the new file, but the old file was already deleted here — leaving connections.json pointing to a missing file with no way to recover the original icon. The same issue exists in removeImage. Consider deferring file deletion until the connection is actually saved.

@kilo-code-bot
Copy link
Copy Markdown

kilo-code-bot Bot commented May 21, 2026

Code Review Summary

Status: 5 Issues Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 1
WARNING 4
SUGGESTION 0

Previously Flagged — Now Resolved

File Line Issue
src/components/modals/NewConnectionModal/AppearanceSection.tsx tab state now re-syncs via userTab/derivedTab + useEffect pattern
src/components/modals/NewConnectionModal/AppearanceSection.tsx emojiDraft state removed; emoji-picker-react used instead
Issue Details (click to expand)

CRITICAL

File Line Issue
src-tauri/src/connection_appearance.rs 115 cascade_delete_if_image lacks path traversal protection — a malicious connections.json can trigger arbitrary file deletion

WARNING

File Line Issue
src-tauri/src/commands.rs 819 Duplicating a connection copies the image path by reference; deleting either connection now cascade-deletes the icon, breaking the other's icon
src/components/modals/NewConnectionModal/AppearanceSection.tsx 102 Eager deletion of previous image file causes data loss on modal cancel (old file gone, JSON still references it)
src/components/modals/NewConnectionModal/AppearanceSection.tsx 181 HexColorInput defaults to prefixed={true}, causing a double # prefix with the external # span
src/utils/connectionIconPack.ts 29 Proxy get trap receives Symbol keys at runtime; camelToKebab throws TypeError on Symbols
Other Observations (not in diff)

No issues found in unchanged code.

Files Reviewed (8 files)
  • package.json
  • pnpm-lock.yaml
  • src/components/modals/NewConnectionModal/AppearanceSection.test.tsx
  • src/components/modals/NewConnectionModal/AppearanceSection.tsx — 2 previous issues remain, 1 new
  • src/utils/connectionIconPack.ts — 1 new
  • src/utils/driverUI.test.tsx
  • src/utils/driverUI.tsx
  • tests/setup.ts

Fix these issues in Kilo Cloud


Reviewed by kimi-k2.6-20260420 · 1,516,149 tokens

…#189)

Replace the manual hex text input and regex validation in AppearanceSection
with HexColorPicker (saturation/hue picker) + HexColorInput (lib-validated
hex input), keeping the 12-swatch curated palette intact. Removes HEX_RE,
customDraft, hexError, and commitCustom (~30 lines). Updates tests to cover
the new panel open/close and live-emit behaviour.
…irectory

The root cause of image upload silently breaking at display time: Tauri 2's
assetProtocol was disabled (default), so convertFileSrc() produced URLs the
webview refused to load. Fix:
- Enable assetProtocol in tauri.conf.json, scoped to $APPDATA/connection-icons/**
- Add fs:scope-appdata-recursive to capabilities so appDataDir() resolves
- Add DEV-only console.error in ConnectionIconImage.onError to surface future failures
…rch, frimousse, image preview (TabularisDB#189)

- Derive active tab reactively from value.icon.type (approach B: userTab ?? derivedTab)
  so reopening an edited connection shows the correct tab without stale useState
- Add preview row at top showing current accent color dot + connection icon + name;
  threads driverManifest and connectionName props from NewConnectionModal
- Add search input above icon pack grid (case-insensitive id filter)
- Replace plain emoji <input> with Frimousse headless picker (built-in search,
  only emits valid single emoji) — removes graphemeCount, emojiDraft, emojiError,
  commitEmoji dead code
- Show ConnectionIconImage thumbnail in Image tab after upload or when editing
  an existing image connection
- Split pickImage() error handling into two distinct try/catch blocks with
  console.error logging so failures point to the exact failing step
… tab sync

- Remove old emoji text-input tests (blur/validation no longer apply)
- Add Frimousse mock to avoid async emoji data fetching in JSDOM
- Add: emoji picker renders with search input, onEmojiSelect emits correctly
- Add: pack icon search filters results by search term
- Add: tab syncs to value.icon.type on external prop change (edit mode)
value.icon?.type === "image" ? "image" : "default";

const [tab, setTab] = useState<IconTab>(initialTab);
const [emojiDraft, setEmojiDraft] = useState(value.icon?.type === "emoji" ? value.icon.value : "");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: emojiDraft state never re-syncs with external value changes

Similar to the tab state issue, emojiDraft is initialized from value.icon?.value but never updates when the value prop changes. If the modal switches to a different connection without unmounting AppearanceSection, the emoji input will display the previous connection's emoji.

/>
<div className="flex items-center gap-2">
<span className="text-zinc-500 text-sm">#</span>
<HexColorInput
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: HexColorInput defaults to prefixed={true}, causing a double # prefix

The manual # span (line 157) combined with HexColorInput's default prefixed={true} behavior means the input displays ##abc123 when a value is present. Add prefixed={false} to HexColorInput and prepend # in the onChange handler (e.g. `#${c.toLowerCase()}`) so the display stays consistent with the intended #rrggbb placeholder.

…abularisDB#189)

Tab state used to stay on the user's last click even after value.icon
changed externally (parent re-render with new initialConnection). Resets
userTab to null when value.icon.type differs from the previous render.

Also enables the protocol-asset Cargo feature on tauri — required at the
crate level for assetProtocol to actually serve files via tauri:// URLs.
Without it, assetProtocol in tauri.conf.json is a no-op.

Minor: memoize filteredPackIcons, route "No preview" through i18n.
…cons (TabularisDB#189)

- Replace Frimousse with emoji-picker-react (dark theme, categories, skin tone selector, recent emojis, preview disabled)
- Expand icon picker from 30 curated icons to all ~1900 lucide icons via dynamicIconImports with lazy loading + 120-result display cap
- Add camelCase→kebab-case normalization for legacy stored icon ids in both resolver and picker
- Wrap lazy icon components in Suspense in driverUI and AppearanceSection pack grid
- Update test mocks: frimousse → emoji-picker-react, dynamicIconImports proxy in setup.ts
- Add camelCase normalization tests in driverUI.test.tsx
throw new Error(`Failed to save icon: ${e}`);
}
if (previousImagePath && previousImagePath !== stored) {
invoke("delete_connection_icon", { relativePath: previousImagePath }).catch(() => {});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Eager deletion of previous image file causes data loss on modal cancel

The old image file is deleted immediately after a new image is picked, but onChange with the new path isn't emitted until the next line. If the user cancels the modal after picking a new image, the old file is gone but connections.json still references it. Defer deletion until the connection is actually saved, or keep both files and garbage-collect orphans periodically.

Comment thread src/utils/connectionIconPack.ts Outdated
// Maps the previous 30-icon kebab-case ids to themselves (lucide uses kebab-case in dynamicIconImports).
// If callers used camelCase (e.g. "shieldCheck"), translate that here.
export const CONNECTION_ICON_PACK = new Proxy({} as Record<string, LazyExoticComponent<LucideIcon>>, {
get(_target, key: string) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: Proxy get trap crashes on Symbol property access

The get trap types key as string, but JavaScript Proxy traps receive Symbol keys at runtime. camelToKebab(key) calls .replace() on the key, which throws TypeError for Symbols. Common operations like Object.prototype.toString.call(CONNECTION_ICON_PACK) or React DevTools inspecting the object can trigger this. Add a typeof key === 'string' guard at the top of the trap before calling string methods.

@NewtTheWolf NewtTheWolf marked this pull request as draft May 21, 2026 12:08
…_icon_for_duplicate (TabularisDB#189)

- cascade_delete_if_image: canonicalize both paths and reject any file
  not under <app_data>/connection-icons/ before deletion
- Add copy_icon_for_duplicate helper that copies icon files with the
  same path-containment guard, so duplicate_connection gives each
  duplicate its own icon file rather than sharing the original path
- Wire copy_icon_for_duplicate into duplicate_connection in commands.rs;
  falls back to dropping the icon on copy failure instead of sharing
- Add regression tests: cascade_delete_rejects_path_traversal,
  copy_icon_for_duplicate_produces_new_filename,
  copy_icon_for_duplicate_rejects_path_traversal
)

Root cause of user data-loss bug: AppearanceSection eagerly called
delete_connection_icon when the user picked a replacement image, so if
the session was later cancelled the "previous" file was already gone but
connections.json still referenced it.

- AppearanceSection: remove eager delete_connection_icon from pickImage
  and removeImage; add onImageUploaded prop so the parent can track uploads
- NewConnectionModal: track all uploaded paths in uploadedPathsRef
- On cancel: delete every session upload except the original image
  (handles "pick A → B → C → cancel" correctly)
- On save: delete every uploaded path except the final one, plus the
  original if the user replaced it
- Add test: "does not eagerly delete previous image on pick"
…risDB#189)

- CONNECTION_ICON_PACK proxy: guard get trap with typeof key !== "string"
  so Symbol accesses (Symbol.toStringTag, Symbol.iterator, etc.) from
  Object.prototype.toString.call(), React DevTools, etc. return undefined
  instead of throwing TypeError in camelToKebab's .replace() call
- HexColorInput: add explicit prefixed={false} to future-proof against
  react-colorful changing its default
- Add tests: Symbol.toStringTag and Symbol.iterator proxy access
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feat]: Custom icon override per database not per database type perse

1 participant