refactor(ui): unify Sync Status icons via Lucide setIcon

The view mixed hand-picked Unicode glyphs (✓ ⚠ ↑ ↓ ⟳ ↻ ✕ ≡ ⎇ 📁),
duplicated across files, which rendered at inconsistent sizes/weights
across platforms and could drift (e.g. the Refresh button used ↻ while
the "checking" status used ⟳).

Centralize every icon in src/ui/components/icons.ts as Lucide icon ids and
render them with Obsidian's setIcon, so status, action-bar, tab, and
info-strip icons all share one consistent icon set. Tabs now derive their
icon from statusMeta so they can no longer diverge from the file list.
Add CSS to size the SVGs uniformly.

Add setIcon to the obsidian test mock and update statusMeta expectations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DwioG4CNKUBuKiZdowLFWe
This commit is contained in:
Claude 2026-06-26 06:22:53 +00:00
parent 339d5cbe77
commit d8d6f9dcb1
No known key found for this signature in database
7 changed files with 100 additions and 41 deletions

View file

@ -1,11 +1,12 @@
import { ItemView, WorkspaceLeaf, TFile, Notice, Platform, setTooltip } from 'obsidian';
import { ItemView, WorkspaceLeaf, TFile, Notice, Platform, setIcon, setTooltip } from 'obsidian';
import GitLabFilesPush from '../main';
import { getServiceName } from '../settings';
import { ConfirmModal } from './ConfirmModal';
import { logger } from '../utils/logger';
import { type FileStatus, type FilterValue } from './types';
import { renderActionBar } from './components/ActionBar';
import { renderFileItem, type FileItemCallbacks } from './components/FileListItem';
import { renderFileItem, statusMeta, type FileItemCallbacks } from './components/FileListItem';
import { ICONS } from './components/icons';
import { isBinaryPath, contentsEqual } from '../utils/path';
export const SYNC_STATUS_VIEW_TYPE = 'sync-status-view';
@ -92,12 +93,16 @@ export class SyncStatusView extends ItemView {
if (!Platform.isMobile) {
el.createSpan({ cls: 'ssv-info-sep', text: '·' });
el.createSpan({ cls: 'ssv-info-item' }).textContent = `${this.plugin.settings.branch}`;
const branchItem = el.createSpan({ cls: 'ssv-info-item' });
setIcon(branchItem.createSpan({ cls: 'ssv-info-icon' }), ICONS.branch);
branchItem.createSpan({ text: ` ${this.plugin.settings.branch}` });
}
if (this.plugin.settings.vaultFolder) {
el.createSpan({ cls: 'ssv-info-sep', text: '·' });
el.createSpan({ cls: 'ssv-info-item', text: `📁 ${this.plugin.settings.vaultFolder}` });
const folderItem = el.createSpan({ cls: 'ssv-info-item' });
setIcon(folderItem.createSpan({ cls: 'ssv-info-icon' }), ICONS.folder);
folderItem.createSpan({ text: ` ${this.plugin.settings.vaultFolder}` });
}
if (this.lastSyncTime > 0) {
@ -123,12 +128,12 @@ export class SyncStatusView extends ItemView {
'remote-only': all.filter(s => s.status === 'remote-only').length,
};
const tabs: Array<{ value: FilterValue; label: string; icon: string }> = [
{ value: 'all', label: 'All', icon: '' },
{ value: 'synced', label: 'Synced', icon: '✓' },
{ value: 'modified', label: 'Changed', icon: '⚠' },
{ value: 'unsynced', label: 'Local only', icon: '↑' },
{ value: 'remote-only', label: 'Remote', icon: '↓' },
const tabs: Array<{ value: FilterValue; label: string }> = [
{ value: 'all', label: 'All' },
{ value: 'synced', label: 'Synced' },
{ value: 'modified', label: 'Changed' },
{ value: 'unsynced', label: 'Local only' },
{ value: 'remote-only', label: 'Remote' },
];
const tabsEl = container.createDiv({ cls: 'ssv-tabs' });
@ -136,7 +141,10 @@ export class SyncStatusView extends ItemView {
const btn = tabsEl.createEl('button', {
cls: `ssv-tab${this.statusFilter === tab.value ? ' active' : ''}`
});
if (tab.icon) btn.createSpan({ text: tab.icon });
// Share the status icon set with the file list so tabs never drift.
if (tab.value !== 'all') {
setIcon(btn.createSpan(), statusMeta(tab.value as FileStatus['status']).icon);
}
btn.createSpan({ cls: 'ssv-tab-label', text: ` ${tab.label}` });
const count = counts[tab.value];
if (tab.value === 'all' || count > 0) {

View file

@ -1,4 +1,5 @@
import { setTooltip } from 'obsidian';
import { setIcon, setTooltip } from 'obsidian';
import { ICONS } from './icons';
export interface ActionBarProps {
hasFiles: boolean;
@ -24,15 +25,15 @@ export function renderActionBar(container: HTMLElement, props: ActionBarProps, c
if (props.hasFiles) {
bar.createDiv({ cls: 'ssv-bar-spacer' });
renderSelectAllRow(bar, props.allSelected, props.indeterminate, callbacks.onSelectAll);
renderLargeButton(bar, '↑', ` Push (${props.canPush})`, `Push ${props.canPush} files`, callbacks.onPush, 'push', props.canPush === 0);
renderLargeButton(bar, '↓', ` Pull (${props.canPull})`, `Pull ${props.canPull} files`, callbacks.onPull, 'pull', props.canPull === 0);
renderLargeButton(bar, '✕', ` Delete (${props.canDelete})`, `Delete ${props.canDelete} files`, callbacks.onDelete, 'danger', props.canDelete === 0);
renderLargeButton(bar, ICONS.push, ` Push (${props.canPush})`, `Push ${props.canPush} files`, callbacks.onPush, 'push', props.canPush === 0);
renderLargeButton(bar, ICONS.pull, ` Pull (${props.canPull})`, `Pull ${props.canPull} files`, callbacks.onPull, 'pull', props.canPull === 0);
renderLargeButton(bar, ICONS.delete, ` Delete (${props.canDelete})`, `Delete ${props.canDelete} files`, callbacks.onDelete, 'danger', props.canDelete === 0);
}
}
function renderRefreshButton(bar: HTMLElement, onRefresh: () => void): void {
const btn = bar.createEl('button', { cls: 'ssv-btn ssv-btn-refresh' });
btn.createSpan({ text: '↻' });
setIcon(btn.createSpan(), ICONS.refresh);
btn.createSpan({ cls: 'ssv-btn-label', text: ' Refresh' });
setTooltip(btn, 'Refresh all statuses');
btn.addEventListener('click', onRefresh);
@ -49,7 +50,7 @@ function renderSelectAllRow(bar: HTMLElement, allSelected: boolean, indeterminat
function renderLargeButton(container: HTMLElement, icon: string, label: string, tooltip: string, onClick: () => void, cls: string, disabled: boolean): void {
const btn = container.createEl('button', { cls: `ssv-btn ssv-btn-${cls}` });
btn.createSpan({ text: icon });
setIcon(btn.createSpan(), icon);
btn.createSpan({ cls: 'ssv-btn-label', text: label });
btn.disabled = disabled;
setTooltip(btn, tooltip);

View file

@ -1,6 +1,7 @@
import { setTooltip } from 'obsidian';
import { setIcon, setTooltip } from 'obsidian';
import { type FileStatus } from '../types';
import { renderDiffPanel } from './DiffPanel';
import { ICONS } from './icons';
export interface FileItemCallbacks {
onSelect: (path: string, selected: boolean) => void;
@ -9,13 +10,15 @@ export interface FileItemCallbacks {
onDelete: (fileStatus: FileStatus) => void;
}
// `icon` is a Lucide icon id (rendered via Obsidian's setIcon) so every status
// uses the same icon set and renders consistently across platforms.
export function statusMeta(status: FileStatus['status']) {
switch (status) {
case 'synced': return { icon: '✓', label: 'Synced', iconCls: 'ssv-icon-synced', badgeCls: 'ssv-badge-synced', fileCls: 'status-synced' };
case 'modified': return { icon: '⚠', label: 'Changed', iconCls: 'ssv-icon-modified', badgeCls: 'ssv-badge-modified', fileCls: 'status-modified' };
case 'unsynced': return { icon: '↑', label: 'Local only', iconCls: 'ssv-icon-unsynced', badgeCls: 'ssv-badge-unsynced', fileCls: 'status-unsynced' };
case 'remote-only': return { icon: '↓', label: 'Remote', iconCls: 'ssv-icon-remote', badgeCls: 'ssv-badge-remote', fileCls: 'status-remote' };
default: return { icon: '⟳', label: 'Checking', iconCls: 'ssv-icon-checking', badgeCls: 'ssv-badge-checking', fileCls: 'status-checking' };
case 'synced': return { icon: ICONS.synced, label: 'Synced', iconCls: 'ssv-icon-synced', badgeCls: 'ssv-badge-synced', fileCls: 'status-synced' };
case 'modified': return { icon: ICONS.modified, label: 'Changed', iconCls: 'ssv-icon-modified', badgeCls: 'ssv-badge-modified', fileCls: 'status-modified' };
case 'unsynced': return { icon: ICONS.push, label: 'Local only', iconCls: 'ssv-icon-unsynced', badgeCls: 'ssv-badge-unsynced', fileCls: 'status-unsynced' };
case 'remote-only': return { icon: ICONS.pull, label: 'Remote', iconCls: 'ssv-icon-remote', badgeCls: 'ssv-badge-remote', fileCls: 'status-remote' };
default: return { icon: ICONS.checking, label: 'Checking', iconCls: 'ssv-icon-checking', badgeCls: 'ssv-badge-checking', fileCls: 'status-checking' };
}
}
@ -33,7 +36,7 @@ export function renderFileItem(
cb.checked = isSelected;
cb.addEventListener('change', () => callbacks.onSelect(fileStatus.path, cb.checked));
row.createSpan({ cls: `ssv-file-icon ${iconCls}`, text: icon });
setIcon(row.createSpan({ cls: `ssv-file-icon ${iconCls}` }), icon);
row.createSpan({ cls: 'ssv-file-path', text: fileStatus.path });
row.createSpan({ cls: `ssv-status-badge ${badgeCls}`, text: label });
@ -50,21 +53,22 @@ function renderFileActions(fileEl: HTMLElement, fileStatus: FileStatus, callback
}
if (fileStatus.status === 'modified' || fileStatus.status === 'unsynced') {
renderActionBtn(actions, '↑', ' Push', 'Push to remote', () => callbacks.onPush(fileStatus), 'push');
renderActionBtn(actions, ICONS.push, ' Push', 'Push to remote', () => callbacks.onPush(fileStatus), 'push');
}
if (fileStatus.status === 'modified' || fileStatus.status === 'remote-only') {
renderActionBtn(actions, '↓', ' Pull', 'Pull from remote', () => callbacks.onPull(fileStatus), 'pull');
renderActionBtn(actions, ICONS.pull, ' Pull', 'Pull from remote', () => callbacks.onPull(fileStatus), 'pull');
}
if (fileStatus.status === 'unsynced') {
renderActionBtn(actions, '✕', ' Remove', 'Delete local file', () => callbacks.onDelete(fileStatus), 'danger');
renderActionBtn(actions, ICONS.delete, ' Remove', 'Delete local file', () => callbacks.onDelete(fileStatus), 'danger');
}
}
function renderDiffToggleButton(actions: HTMLElement, fileEl: HTMLElement, fileStatus: FileStatus): void {
const diffBtn = actions.createEl('button', { cls: 'ssv-action-btn diff' });
diffBtn.createSpan({ text: '≡' });
const iconEl = diffBtn.createSpan();
setIcon(iconEl, ICONS.diff);
const btnLabel = diffBtn.createSpan({ cls: 'ssv-btn-label', text: ' Diff' });
let diffEl: HTMLElement;
@ -80,16 +84,13 @@ function renderDiffToggleButton(actions: HTMLElement, fileEl: HTMLElement, fileS
const open = diffEl.hasClass('visible');
diffEl.toggleClass('visible', !open);
btnLabel.setText(open ? ' Diff' : ' Hide');
const firstChild = diffBtn.firstChild;
if (firstChild instanceof HTMLElement || firstChild instanceof Text) {
firstChild.textContent = open ? '≡' : '▴';
}
setIcon(iconEl, open ? ICONS.diff : ICONS.diffOpen);
});
}
function renderActionBtn(actions: HTMLElement, icon: string, label: string, tooltip: string, onClick: () => void, cls: string): void {
const btn = actions.createEl('button', { cls: `ssv-action-btn ${cls}` });
btn.createSpan({ text: icon });
setIcon(btn.createSpan(), icon);
btn.createSpan({ cls: 'ssv-btn-label', text: label });
setTooltip(btn, tooltip);
btn.addEventListener('click', onClick);

View file

@ -0,0 +1,21 @@
// Single source of truth for every icon used in the Sync Status view.
//
// Values are Lucide icon ids rendered through Obsidian's `setIcon`, so all
// icons share one visual style (stroke weight, size, baseline) and stay
// consistent across desktop and mobile. Reuse these constants everywhere a
// status or action icon is shown instead of inlining Unicode glyphs.
export const ICONS = {
// Status / action
synced: 'check',
modified: 'pencil',
push: 'arrow-up',
pull: 'arrow-down',
checking: 'refresh-cw',
refresh: 'refresh-cw',
delete: 'trash-2',
diff: 'file-diff',
diffOpen: 'chevron-up',
// Info strip
branch: 'git-branch',
folder: 'folder',
} as const;

View file

@ -295,11 +295,11 @@
}
.ssv-file-icon {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
text-align: center;
flex-shrink: 0;
font-size: 0.95em;
font-weight: 600;
}
.ssv-icon-synced { color: var(--color-green); }
@ -346,6 +346,9 @@
}
.ssv-action-btn {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
font-size: 0.76em;
border-radius: 4px;
@ -358,6 +361,29 @@
transition: background 0.1s, opacity 0.1s;
}
/* Consistent sizing for all Lucide icons used in the view */
.ssv-file-icon .svg-icon {
width: 16px;
height: 16px;
}
.ssv-btn .svg-icon,
.ssv-tab .svg-icon,
.ssv-action-btn .svg-icon {
width: 15px;
height: 15px;
}
.ssv-info-icon {
display: inline-flex;
align-items: center;
}
.ssv-info-icon .svg-icon {
width: 13px;
height: 13px;
}
.is-mobile .ssv-action-btn {
padding: 8px 15px;
font-size: 0.85em;

View file

@ -56,6 +56,7 @@ export const App = class {
export const TFile = class {};
export const requestUrl = vi.fn();
export const setTooltip = vi.fn();
export const setIcon = vi.fn();
vi.mock('obsidian', () => ({
Plugin,
@ -69,4 +70,5 @@ vi.mock('obsidian', () => ({
TFile,
requestUrl,
setTooltip,
setIcon,
}));

View file

@ -14,11 +14,11 @@ function makeFileStatus(status: FileStatus['status'], overrides?: Partial<FileSt
describe('statusMeta', () => {
it.each([
['synced', '✓', 'Synced', 'status-synced'],
['modified', '⚠', 'Changed', 'status-modified'],
['unsynced', '↑', 'Local only', 'status-unsynced'],
['remote-only', '', 'Remote', 'status-remote'],
['checking', '', 'Checking', 'status-checking'],
['synced', 'check', 'Synced', 'status-synced'],
['modified', 'pencil', 'Changed', 'status-modified'],
['unsynced', 'arrow-up', 'Local only', 'status-unsynced'],
['remote-only', 'arrow-down', 'Remote', 'status-remote'],
['checking', 'refresh-cw', 'Checking', 'status-checking'],
] as const)('%s: returns correct icon, label, and fileCls', (status, icon, label, fileCls) => {
const meta = statusMeta(status);
expect(meta.icon).toBe(icon);