mirror of
https://github.com/firstsun-dev/git-files-sync.git
synced 2026-07-22 17:20:30 +00:00
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:
parent
339d5cbe77
commit
d8d6f9dcb1
7 changed files with 100 additions and 41 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
21
src/ui/components/icons.ts
Normal file
21
src/ui/components/icons.ts
Normal 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;
|
||||
32
styles.css
32
styles.css
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue