From 31ac9189cf921f49fb891adbf45d15b07d2cf049 Mon Sep 17 00:00:00 2001 From: ClaudiaFang Date: Wed, 20 May 2026 04:55:26 +0000 Subject: [PATCH] refactor: code quality enhancements for issue #23 - Fix plugin scanner warnings: remove !important from styles.css, fix CSS shorthand (0 0 8px), use activeWindow.setTimeout(), remove .zip from release assets - Extract LCS diff algorithm to src/utils/diff.ts with full unit tests - Extract UI render components: ActionBar, FileListItem, DiffPanel reducing SyncStatusView from 853 to 523 lines - Add shared types in src/ui/types.ts - Add unified logger in src/utils/logger.ts replacing 9 console.* calls Co-Authored-By: Claude Sonnet 4.6 --- .releaserc.json | 6 +- src/logic/gitignore-manager.ts | 5 +- src/logic/sync-manager.ts | 5 +- src/main.ts | 5 +- src/services/git-service-base.ts | 3 +- src/services/github-service.ts | 3 +- src/ui/SyncStatusView.ts | 486 +++++------------------------- src/ui/components/ActionBar.ts | 57 ++++ src/ui/components/DiffPanel.ts | 31 ++ src/ui/components/FileListItem.ts | 88 ++++++ src/ui/types.ts | 13 + src/utils/diff.ts | 154 ++++++++++ src/utils/logger.ts | 6 + styles.css | 22 +- tests/utils/diff.test.ts | 183 +++++++++++ 15 files changed, 637 insertions(+), 430 deletions(-) create mode 100644 src/ui/components/ActionBar.ts create mode 100644 src/ui/components/DiffPanel.ts create mode 100644 src/ui/components/FileListItem.ts create mode 100644 src/ui/types.ts create mode 100644 src/utils/diff.ts create mode 100644 src/utils/logger.ts create mode 100644 tests/utils/diff.test.ts diff --git a/.releaserc.json b/.releaserc.json index 9e031a3..2c02683 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -59,8 +59,7 @@ [ "@semantic-release/exec", { - "prepareCmd": "node -e \"const fs=require('fs'); const m=JSON.parse(fs.readFileSync('manifest.json')); m.version='${nextRelease.version}'; fs.writeFileSync('manifest.json', JSON.stringify(m,null,'\\t')); const v=JSON.parse(fs.readFileSync('versions.json')); v['${nextRelease.version}']=m.minAppVersion; fs.writeFileSync('versions.json', JSON.stringify(v,null,'\\t'));\"", - "publishCmd": "zip -j git-files-sync-${nextRelease.version}.zip main.js manifest.json styles.css" + "prepareCmd": "node -e \"const fs=require('fs'); const m=JSON.parse(fs.readFileSync('manifest.json')); m.version='${nextRelease.version}'; fs.writeFileSync('manifest.json', JSON.stringify(m,null,'\\t')); const v=JSON.parse(fs.readFileSync('versions.json')); v['${nextRelease.version}']=m.minAppVersion; fs.writeFileSync('versions.json', JSON.stringify(v,null,'\\t'));\"" } ], [ @@ -76,8 +75,7 @@ "assets": [ { "path": "main.js" }, { "path": "manifest.json" }, - { "path": "styles.css" }, - { "path": "git-files-sync-*.zip", "label": "Plugin Package (Zip)" } + { "path": "styles.css" } ] } ] diff --git a/src/logic/gitignore-manager.ts b/src/logic/gitignore-manager.ts index 5e30996..e570646 100644 --- a/src/logic/gitignore-manager.ts +++ b/src/logic/gitignore-manager.ts @@ -1,6 +1,7 @@ import ignore, { Ignore } from 'ignore'; import { App } from 'obsidian'; import { GitServiceInterface } from '../services/git-service-interface'; +import { logger } from '../utils/logger'; export class GitignoreManager { private readonly app: App; @@ -30,7 +31,7 @@ export class GitignoreManager { try { gitignorePaths = await this.gitService.getRepoGitignores(this.branch); } catch (e) { - console.warn('Failed to fetch repo gitignores', e); + logger.warn('Failed to fetch repo gitignores', e); // Fallback to at least checking the root gitignorePaths = ['.gitignore']; } @@ -65,7 +66,7 @@ export class GitignoreManager { content = await this.app.vault.adapter.read(localPath); } } catch (e) { - console.warn(`Failed to read local ${localPath}`, e); + logger.warn(`Failed to read local ${localPath}`, e); } } diff --git a/src/logic/sync-manager.ts b/src/logic/sync-manager.ts index 2264d2b..0f3e0ed 100644 --- a/src/logic/sync-manager.ts +++ b/src/logic/sync-manager.ts @@ -2,6 +2,7 @@ import { TFile, App, Notice } from 'obsidian'; import { GitServiceInterface } from '../services/git-service-interface'; import { GitLabFilesPushSettings, getServiceName } from '../settings'; import { SyncConflictModal } from '../ui/SyncConflictModal'; +import { logger } from '../utils/logger'; export class SyncManager { private readonly app: App; @@ -228,7 +229,7 @@ export class SyncManager { } private handleError(message: string, error: unknown): void { - console.error(message, error); + logger.error(message, error); const detail = error instanceof Error ? error.message : String(error); new Notice(`${message}: ${detail}`); } @@ -263,7 +264,7 @@ export class SyncManager { } results.success++; } catch (e) { - console.error(`Failed to ${op} ${path}:`, e); + logger.error(`Failed to ${op} ${path}:`, e); results.failed++; results.errors.push({ file: path, error: e instanceof Error ? e.message : String(e) }); } diff --git a/src/main.ts b/src/main.ts index 261f4c5..c34810c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,6 +6,7 @@ import { GitServiceInterface } from './services/git-service-interface'; import { SyncManager } from './logic/sync-manager'; import { SyncStatusView, SYNC_STATUS_VIEW_TYPE } from './ui/SyncStatusView'; import { GitignoreManager } from './logic/gitignore-manager'; +import { logger } from './utils/logger'; import { ConfirmModal } from './ui/ConfirmModal'; export default class GitLabFilesPush extends Plugin { @@ -171,11 +172,11 @@ export default class GitLabFilesPush extends Plugin { progressNotice.hide(); if (results.errors.length > 0) { - console.error(`${op} errors:`, results.errors); + logger.error(`${op} errors:`, results.errors); } } catch (e) { progressNotice.hide(); - console.error(e); + logger.error(String(e)); new Notice(`${op === 'push' ? 'Push' : 'Pull'} failed: ${e instanceof Error ? e.message : String(e)}`); } } diff --git a/src/services/git-service-base.ts b/src/services/git-service-base.ts index 453605f..3ecaddb 100644 --- a/src/services/git-service-base.ts +++ b/src/services/git-service-base.ts @@ -1,4 +1,5 @@ import { requestUrl, RequestUrlResponse } from 'obsidian'; +import { logger } from '../utils/logger'; export interface GitFile { content: string; @@ -65,7 +66,7 @@ export abstract class BaseGitService { return response; } catch (error) { - console.error('Git Service Request Failed:', error); + logger.error('Git Service Request Failed:', error); if (error instanceof Error) throw error; throw new Error(`Network error or unexpected failure: ${String(error)}`); } diff --git a/src/services/github-service.ts b/src/services/github-service.ts index 9d8070c..173434b 100644 --- a/src/services/github-service.ts +++ b/src/services/github-service.ts @@ -1,5 +1,6 @@ import { GitServiceInterface } from './git-service-interface'; import { BaseGitService, GitFile, GitHubContentResponse, GitHubTreeResponse } from './git-service-base'; +import { logger } from '../utils/logger'; export class GitHubService extends BaseGitService implements GitServiceInterface { private owner: string = ''; @@ -56,7 +57,7 @@ export class GitHubService extends BaseGitService implements GitServiceInterface const data = response.json as GitHubTreeResponse; if (data.truncated) { - console.warn('GitHub tree result is truncated. Some files might not be shown.'); + logger.warn('GitHub tree result is truncated. Some files might not be shown.'); } return data.tree diff --git a/src/ui/SyncStatusView.ts b/src/ui/SyncStatusView.ts index 8ef30f5..b445b59 100644 --- a/src/ui/SyncStatusView.ts +++ b/src/ui/SyncStatusView.ts @@ -2,40 +2,13 @@ import { ItemView, WorkspaceLeaf, TFile, Notice, Platform, setTooltip } from 'ob 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 } from './components/FileListItem'; export const SYNC_STATUS_VIEW_TYPE = 'sync-status-view'; -interface FileStatus { - file?: TFile; - path: string; - status: 'synced' | 'modified' | 'unsynced' | 'remote-only' | 'checking'; - localContent?: string; - remoteContent?: string; - remoteSha?: string; - diff?: string; -} - -interface DiffSide { - lineNum: number | null; - content: string | null; - type: 'removed' | 'added' | 'unchanged' | 'empty'; -} - -interface DiffRow { - left: DiffSide; - right: DiffSide; -} - -type FilterValue = 'all' | 'synced' | 'modified' | 'unsynced' | 'remote-only'; - -type DiffOpType = 'unchanged' | 'removed' | 'added'; - -interface DiffOp { - type: DiffOpType; - li: number; - ri: number; -} - export class SyncStatusView extends ItemView { plugin: GitLabFilesPush; private readonly fileStatuses: Map = new Map(); @@ -69,7 +42,7 @@ export class SyncStatusView extends ItemView { this.renderInfoStrip(container); this.renderTabs(container); - this.renderActionBar(container); + this.renderActionBarSection(container); const listEl = container.createDiv({ cls: 'ssv-list' }); if (this.fileStatuses.size === 0) { @@ -79,6 +52,8 @@ export class SyncStatusView extends ItemView { } } + // ── Info strip ───────────────────────────────────────────────── + private renderInfoStrip(container: HTMLElement): void { const el = container.createDiv({ cls: 'ssv-info' }); const serviceName = getServiceName(this.plugin.settings); @@ -87,8 +62,7 @@ export class SyncStatusView extends ItemView { if (!Platform.isMobile) { el.createSpan({ cls: 'ssv-info-sep', text: '·' }); - const branchEl = el.createSpan({ cls: 'ssv-info-item' }); - branchEl.textContent = `⎇ ${this.plugin.settings.branch}`; + el.createSpan({ cls: 'ssv-info-item' }).textContent = `⎇ ${this.plugin.settings.branch}`; } if (this.plugin.settings.vaultFolder) { @@ -100,11 +74,15 @@ export class SyncStatusView extends ItemView { el.createSpan({ cls: 'ssv-info-sep', text: '·' }); el.createSpan({ cls: 'ssv-info-time', - text: Platform.isMobile ? new Date(this.lastSyncTime).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}) : `Last sync: ${new Date(this.lastSyncTime).toLocaleTimeString()}` + text: Platform.isMobile + ? new Date(this.lastSyncTime).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + : `Last sync: ${new Date(this.lastSyncTime).toLocaleTimeString()}` }); } } + // ── Filter tabs ───────────────────────────────────────────────── + private renderTabs(container: HTMLElement): void { const all = Array.from(this.fileStatuses.values()); const counts: Record = { @@ -143,71 +121,38 @@ export class SyncStatusView extends ItemView { } } - private renderActionBar(container: HTMLElement): void { - const { visible, canPush, canPull, canDelete, allSelected } = this.getActionBarState(); - const bar = container.createDiv({ cls: 'ssv-action-bar' }); + // ── Action bar ───────────────────────────────────────────────── - this.renderRefreshButton(bar); - - if (this.fileStatuses.size > 0) { - bar.createDiv({ cls: 'ssv-bar-spacer' }); - this.renderSelectAllRow(bar, allSelected, visible); - this.renderActionButtons(bar, canPush, canPull, canDelete); - } - } - - private getActionBarState() { + private renderActionBarSection(container: HTMLElement): void { const all = Array.from(this.fileStatuses.values()); const visible = this.statusFilter === 'all' ? all : all.filter(s => s.status === this.statusFilter); - const selected = Array.from(this.selectedFiles).map(p => this.fileStatuses.get(p)).filter(Boolean) as FileStatus[]; + const selected = Array.from(this.selectedFiles) + .map(p => this.fileStatuses.get(p)) + .filter(Boolean) as FileStatus[]; - return { - visible, - canPush: selected.filter(s => s.file && (s.status === 'modified' || s.status === 'unsynced')).length, - canPull: selected.filter(s => s.status === 'modified' || s.status === 'remote-only').length, + const allSelected = visible.length > 0 && visible.every(s => this.selectedFiles.has(s.path)); + + renderActionBar(container, { + hasFiles: this.fileStatuses.size > 0, + allSelected, + indeterminate: this.selectedFiles.size > 0 && !allSelected, + canPush: selected.filter(s => s.file && (s.status === 'modified' || s.status === 'unsynced')).length, + canPull: selected.filter(s => s.status === 'modified' || s.status === 'remote-only').length, canDelete: selected.filter(s => s.file || s.status === 'remote-only').length, - allSelected: visible.length > 0 && visible.every(s => this.selectedFiles.has(s.path)) - }; - } - - private renderRefreshButton(bar: HTMLElement): void { - const btn = bar.createEl('button', { cls: 'ssv-btn ssv-btn-refresh' }); - btn.createSpan({ text: '↻' }); - btn.createSpan({ cls: 'ssv-btn-label', text: ' Refresh' }); - setTooltip(btn, 'Refresh all statuses'); - btn.addEventListener('click', () => void this.refreshAllStatuses()); - } - - private renderSelectAllRow(bar: HTMLElement, allSelected: boolean, visible: FileStatus[]): void { - const selectRow = bar.createDiv({ cls: 'ssv-select-row' }); - const cb = selectRow.createEl('input', { type: 'checkbox' }); - cb.checked = allSelected; - cb.indeterminate = this.selectedFiles.size > 0 && !allSelected; - selectRow.createSpan({ cls: 'ssv-select-label', text: 'Select' }); - cb.addEventListener('change', () => { - if (cb.checked) { - for (const s of visible) this.selectedFiles.add(s.path); - } else { - this.selectedFiles.clear(); - } - this.renderView(); + }, { + onRefresh: () => void this.refreshAllStatuses(), + onSelectAll: (select) => { + if (select) { for (const s of visible) this.selectedFiles.add(s.path); } + else { this.selectedFiles.clear(); } + this.renderView(); + }, + onPush: () => void this.pushSelected(), + onPull: () => void this.pullSelected(), + onDelete: () => void this.deleteSelected(), }); } - private renderActionButtons(bar: HTMLElement, canPush: number, canPull: number, canDelete: number): void { - this.renderLargeButton(bar, '↑', ` Push (${canPush})`, `Push ${canPush} files`, () => void this.pushSelected(), 'push', canPush === 0); - this.renderLargeButton(bar, '↓', ` Pull (${canPull})`, `Pull ${canPull} files`, () => void this.pullSelected(), 'pull', canPull === 0); - this.renderLargeButton(bar, '✕', ` Delete (${canDelete})`, `Delete ${canDelete} files`, () => void this.deleteSelected(), 'danger', canDelete === 0); - } - - private 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 }); - btn.createSpan({ cls: 'ssv-btn-label', text: label }); - btn.disabled = disabled; - setTooltip(btn, tooltip); - btn.addEventListener('click', onClick); - } + // ── File list ────────────────────────────────────────────────── private renderFileList(container: HTMLElement): void { const all = Array.from(this.fileStatuses.values()); @@ -219,82 +164,22 @@ export class SyncStatusView extends ItemView { container.createDiv({ cls: 'ssv-empty', text: `No ${this.statusFilter} files` }); return; } - for (const fs of statuses) this.renderFileItem(container, fs); - } - private renderFileItem(container: HTMLElement, fileStatus: FileStatus): void { - const { icon, label, iconCls, badgeCls, fileCls } = this.statusMeta(fileStatus.status); - const fileEl = container.createDiv({ cls: `ssv-file ${fileCls}` }); - - const row = fileEl.createDiv({ cls: 'ssv-file-row' }); - this.renderFileCheckbox(row, fileStatus); - - row.createSpan({ cls: `ssv-file-icon ${iconCls}`, text: icon }); - row.createSpan({ cls: 'ssv-file-path', text: fileStatus.path }); - row.createSpan({ cls: `ssv-status-badge ${badgeCls}`, text: label }); - - if (fileStatus.status !== 'synced' && fileStatus.status !== 'checking') { - this.renderFileActions(fileEl, fileStatus); + for (const fs of statuses) { + renderFileItem(container, fs, this.selectedFiles.has(fs.path), { + onSelect: (path, selected) => { + if (selected) this.selectedFiles.add(path); + else this.selectedFiles.delete(path); + this.renderView(); + }, + onPush: (fileStatus) => void this.runSingleFile(fileStatus, 'push'), + onPull: (fileStatus) => void this.runSingleFile(fileStatus, 'pull'), + onDelete: (fileStatus) => void this.handleLocalDelete(fileStatus), + }); } } - private renderFileCheckbox(row: HTMLElement, fileStatus: FileStatus): void { - const cb = row.createEl('input', { type: 'checkbox', cls: 'ssv-file-checkbox' }); - cb.checked = this.selectedFiles.has(fileStatus.path); - cb.addEventListener('change', () => { - if (cb.checked) { - this.selectedFiles.add(fileStatus.path); - } else { - this.selectedFiles.delete(fileStatus.path); - } - this.renderView(); - }); - } - - private renderFileActions(fileEl: HTMLElement, fileStatus: FileStatus): void { - const actions = fileEl.createDiv({ cls: 'ssv-file-actions' }); - - if (fileStatus.status === 'modified' && fileStatus.diff) { - this.renderDiffToggleButton(actions, fileEl, fileStatus); - } - - if ((fileStatus.status === 'modified' || fileStatus.status === 'unsynced') && fileStatus.file) { - this.renderActionButton(actions, '↑', ' Push', 'Push to remote', () => void this.runSingleFile(fileStatus, 'push'), 'push'); - } - - if (fileStatus.status === 'modified' || fileStatus.status === 'remote-only') { - this.renderActionButton(actions, '↓', ' Pull', 'Pull from remote', () => void this.runSingleFile(fileStatus, 'pull'), 'pull'); - } - - if (fileStatus.status === 'unsynced' && fileStatus.file) { - this.renderActionButton(actions, '✕', ' Remove', 'Delete local file', () => void this.handleLocalDelete(fileStatus), 'danger'); - } - } - - private renderDiffToggleButton(actions: HTMLElement, fileEl: HTMLElement, fileStatus: FileStatus): void { - const diffBtn = actions.createEl('button', { cls: 'ssv-action-btn diff' }); - diffBtn.createSpan({ text: '≡' }); - const btnLabel = diffBtn.createSpan({ cls: 'ssv-btn-label', text: ' Diff' }); - const diffEl = this.renderDiffPanel(fileEl, fileStatus); - setTooltip(diffBtn, 'Toggle diff view'); - diffBtn.addEventListener('click', () => { - 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 ? '≡' : '▴'; - } - }); - } - - private renderActionButton(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 }); - btn.createSpan({ cls: 'ssv-btn-label', text: label }); - setTooltip(btn, tooltip); - btn.addEventListener('click', onClick); - } + // ── Single-file operations ────────────────────────────────────── private async handleLocalDelete(fileStatus: FileStatus): Promise { const confirmed = await this.showConfirmDialog(`Delete local file "${fileStatus.path}"?`); @@ -324,7 +209,8 @@ export class SyncStatusView extends ItemView { await this.plugin.sync.pullFile(fileStatus.file || fileStatus.path); } - await new Promise(r => setTimeout(r, 500)); + // eslint-disable-next-line no-undef + await new Promise(r => activeWindow.setTimeout(r, 500)); await this.refreshFileStatus(fileStatus.file || fileStatus.path); this.renderView(); } catch (e) { @@ -334,196 +220,7 @@ export class SyncStatusView extends ItemView { } } - private 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' }; - } - } - - // ── Side-by-side diff ───────────────────────────────────────── - - private renderDiffPanel(fileEl: HTMLElement, fileStatus: FileStatus): HTMLElement { - const diffEl = fileEl.createDiv({ cls: 'ssv-diff' }); - const rows = this.computeSideBySideDiff(fileStatus.remoteContent ?? '', fileStatus.localContent ?? ''); - - // Side-by-side (shown on wide containers via container query) - const splitEl = diffEl.createDiv({ cls: 'ssv-diff-split' }); - const grid = splitEl.createDiv({ cls: 'ssv-diff-grid' }); - grid.createDiv({ cls: 'ssv-diff-hd', text: 'Remote' }); - grid.createDiv({ cls: 'ssv-diff-hd', text: 'Local' }); - for (const row of rows) { - this.renderDiffCell(grid, row.left); - this.renderDiffCell(grid, row.right); - } - - // Unified (shown on narrow containers / mobile via container query) - const unifiedEl = diffEl.createEl('pre', { cls: 'ssv-diff-unified' }); - for (const row of rows) { - const { left, right } = row; - if (left.type === 'removed') { - unifiedEl.createSpan({ cls: 'ssv-u-line removed' }).textContent = `- ${left.content ?? ''}\n`; - } - if (right.type === 'added') { - unifiedEl.createSpan({ cls: 'ssv-u-line added' }).textContent = `+ ${right.content ?? ''}\n`; - } - if (left.type === 'unchanged') { - unifiedEl.createSpan({ cls: 'ssv-u-line unchanged' }).textContent = ` ${left.content ?? ''}\n`; - } - } - - return diffEl; - } - - private renderDiffCell(grid: HTMLElement, side: DiffSide): void { - const cell = grid.createDiv({ cls: `ssv-diff-cell ${side.type}` }); - cell.createSpan({ cls: 'ssv-diff-ln' }).textContent = side.lineNum === null ? '' : String(side.lineNum); - if (side.content !== null) { - cell.createSpan({ cls: 'ssv-diff-code' }).textContent = side.content; - } - } - - private computeSideBySideDiff(remote: string, local: string): DiffRow[] { - const L = this.normalizeContent(remote).split('\n'); - const R = this.normalizeContent(local).split('\n'); - const m = L.length, n = R.length; - - if (m * n > 250_000 || (m + 1) * (n + 1) > 1_000_000) { - return this.simpleDiff(L, R); - } - - const dp = this.buildDPMatrix(L, R, m, n); - const ops = this.tracePath(L, R, dp, m, n); - return this.pairDiffOps(ops, L, R); - } - - private normalizeContent(s: string): string { - return s.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); - } - - private buildDPMatrix(L: string[], R: string[], m: number, n: number): Uint32Array { - const W = n + 1; - const dp = new Uint32Array((m + 1) * W); - for (let i = 1; i <= m; i++) { - for (let j = 1; j <= n; j++) { - dp[i * W + j] = L[i - 1] === R[j - 1] - ? (dp[(i - 1) * W + (j - 1)]!) + 1 - : Math.max(dp[(i - 1) * W + j]!, dp[i * W + (j - 1)]!); - } - } - return dp; - } - - private tracePath(L: string[], R: string[], dp: Uint32Array, m: number, n: number): DiffOp[] { - const W = n + 1; - const ops: DiffOp[] = []; - let i = m, j = n; - while (i > 0 || j > 0) { - const op = this.getNextDiffOp(L, R, dp, W, i, j); - ops.push(op); - [i, j] = this.updateIndices(op, i, j); - } - return ops.reverse(); - } - - private updateIndices(op: DiffOp, i: number, j: number): [number, number] { - if (op.type === 'unchanged') return [i - 1, j - 1]; - if (op.type === 'added') return [i, j - 1]; - return [i - 1, j]; - } - - private getNextDiffOp(L: string[], R: string[], dp: Uint32Array, W: number, i: number, j: number): DiffOp { - if (i > 0 && j > 0 && L[i - 1] === R[j - 1]) { - return { type: 'unchanged', li: i - 1, ri: j - 1 }; - } - - const canAdd = j > 0; - const preferAdd = canAdd && (i === 0 || dp[i * W + (j - 1)]! >= dp[(i - 1) * W + j]!); - - if (preferAdd) { - return { type: 'added', li: -1, ri: j - 1 }; - } - return { type: 'removed', li: i - 1, ri: -1 }; - } - - private pairDiffOps(ops: DiffOp[], L: string[], R: string[]): DiffRow[] { - const rows: DiffRow[] = []; - let k = 0; - while (k < ops.length) { - const op = ops[k]; - if (!op) break; - - if (op.type === 'unchanged') { - rows.push(this.createUnchangedRow(op, L, R)); - k++; - } else { - const batch = this.collectChangeBatch(ops, k); - rows.push(...this.createChangeRows(batch, L, R)); - k += batch.length; - } - } - return rows; - } - - private createUnchangedRow(op: DiffOp, L: string[], R: string[]): DiffRow { - return { - left: { lineNum: op.li + 1, content: L[op.li] ?? null, type: 'unchanged' }, - right: { lineNum: op.ri + 1, content: R[op.ri] ?? null, type: 'unchanged' }, - }; - } - - private collectChangeBatch(ops: DiffOp[], startIdx: number): DiffOp[] { - const batch: DiffOp[] = []; - let k = startIdx; - while (k < ops.length) { - const item = ops[k]; - if (!item || item.type === 'unchanged') break; - batch.push(item); - k++; - } - return batch; - } - - private createChangeRows(batch: DiffOp[], L: string[], R: string[]): DiffRow[] { - const removedIdxs = batch.filter(o => o.type === 'removed').map(o => o.li); - const addedIdxs = batch.filter(o => o.type === 'added').map(o => o.ri); - const len = Math.max(removedIdxs.length, addedIdxs.length); - const rows: DiffRow[] = []; - - for (let x = 0; x < len; x++) { - rows.push({ - left: this.createDiffSide(removedIdxs[x], L, 'removed'), - right: this.createDiffSide(addedIdxs[x], R, 'added') - }); - } - return rows; - } - - private createDiffSide(idx: number | undefined, lines: string[], type: 'removed' | 'added'): DiffSide { - if (idx === undefined) { - return { lineNum: null, content: null, type: 'empty' }; - } - return { lineNum: idx + 1, content: lines[idx] ?? null, type }; - } - - // Fallback for very large files (> 500×500 lines) - private simpleDiff(L: string[], R: string[]): DiffRow[] { - const rows: DiffRow[] = []; - const max = Math.max(L.length, R.length); - for (let i = 0; i < max; i++) { - const l = L[i], r = R[i]; - if (l === undefined) rows.push({ left: { lineNum: null, content: null, type: 'empty' }, right: { lineNum: i + 1, content: r ?? null, type: 'added' } }); - else if (r === undefined) rows.push({ left: { lineNum: i + 1, content: l, type: 'removed' }, right: { lineNum: null, content: null, type: 'empty' } }); - else if (l === r) rows.push({ left: { lineNum: i + 1, content: l, type: 'unchanged' }, right: { lineNum: i + 1, content: r, type: 'unchanged' } }); - else rows.push({ left: { lineNum: i + 1, content: l, type: 'removed' }, right: { lineNum: i + 1, content: r, type: 'added' } }); - } - return rows; - } - - // ── Batch / refresh operations (logic unchanged) ────────────── + // ── Batch / refresh operations ───────────────────────────────── async refreshAllStatuses(): Promise { if (this.isRefreshing) { @@ -565,8 +262,7 @@ export class SyncStatusView extends ItemView { const prog = listEl.createDiv({ cls: 'ssv-progress' }); prog.createDiv({ cls: 'ssv-progress-text', text: 'Checking files…' }); const bar = prog.createDiv({ cls: 'ssv-progress-bar' }); - const fill = bar.createDiv({ cls: 'ssv-progress-fill' }); - fill.setAttr('style', 'width: 0%'); + bar.createDiv({ cls: 'ssv-progress-fill' }).setAttr('style', 'width: 0%'); } private async discoverFiles() { @@ -576,13 +272,13 @@ export class SyncStatusView extends ItemView { await this.plugin.gitignoreManager.loadGitignores(); remote = remote.filter(p => !this.plugin.gitignoreManager.isIgnored(p)); - local = local.filter(f => !this.plugin.gitignoreManager.isIgnored(f.path)); + local = local.filter(f => !this.plugin.gitignoreManager.isIgnored(f.path)); return { local, remote, localMap: new Set(local.map(f => f.path)), - allMap: new Map(allFiles.map(f => [f.path, f])) + allMap: new Map(allFiles.map(f => [f.path, f])) }; } @@ -623,8 +319,7 @@ export class SyncStatusView extends ItemView { } private getCheckableFiles(local: TFile[], extra: Array) { - const combined: Array = [...local, ...extra]; - return combined.filter(f => { + return ([...local, ...extra] as Array).filter(f => { const p = typeof f === 'string' ? f : f.path; return !this.plugin.gitignoreManager.isIgnored(p); }); @@ -634,9 +329,7 @@ export class SyncStatusView extends ItemView { const total = filesToCheck.length; for (let i = 0; i < total; i++) { const file = filesToCheck[i]; - if (file) { - await this.refreshFileStatus(file); - } + if (file) await this.refreshFileStatus(file); this.updateRefreshProgress(i + 1, total); } } @@ -703,21 +396,20 @@ export class SyncStatusView extends ItemView { return diff.join('\n'); } - async pushAllModified(): Promise { - await this.runBatchOperation('modified', 'push'); - } + // ── Batch push/pull/delete ───────────────────────────────────── - async pullAllModified(): Promise { - await this.runBatchOperation('modified', 'pull'); - } + async pushAllModified(): Promise { await this.runBatchOperation('modified', 'push'); } + async pullAllModified(): Promise { await this.runBatchOperation('modified', 'pull'); } + async pushSelected(): Promise { await this.runBatchOperation('selected', 'push'); } + async pullSelected(): Promise { await this.runBatchOperation('selected', 'pull'); } private async runBatchOperation(filter: 'modified' | 'selected', op: 'push' | 'pull'): Promise { - const targets = Array.from(this.fileStatuses.values()) - .filter(s => { - if (filter === 'selected' && !this.selectedFiles.has(s.path)) return false; - if (op === 'push') return s.status === 'modified' || s.status === 'unsynced'; - return s.status === 'modified' || s.status === 'remote-only'; - }); + const targets = Array.from(this.fileStatuses.values()).filter(s => { + if (filter === 'selected' && !this.selectedFiles.has(s.path)) return false; + return op === 'push' + ? s.status === 'modified' || s.status === 'unsynced' + : s.status === 'modified' || s.status === 'remote-only'; + }); if (targets.length === 0) { new Notice(`No ${op}able files ${filter === 'selected' ? 'selected' : 'found'}.`); @@ -726,7 +418,7 @@ export class SyncStatusView extends ItemView { const files = targets.map(s => s.file || s.path); const serviceName = getServiceName(this.plugin.settings); - const msg = op === 'push' + const msg = op === 'push' ? `Push ${files.length} file(s) to ${serviceName}?` : `Pull ${files.length} file(s) from ${serviceName}? This will overwrite local changes.`; @@ -739,9 +431,8 @@ export class SyncStatusView extends ItemView { : await this.plugin.sync.pullAllFiles(files, (cur, total, name) => prog.setMessage(`Pulling ${cur}/${total}: ${name}`)); prog.hide(); - if (results.errors.length > 0) console.error(`${op} errors:`, results.errors); + if (results.errors.length > 0) logger.error(`${op} errors:`, results.errors); if (filter === 'selected') this.selectedFiles.clear(); - new Notice(`${op === 'push' ? 'Push' : 'Pull'} completed. Refreshing…`); await this.refreshAllStatuses(); } catch (e) { @@ -750,21 +441,12 @@ export class SyncStatusView extends ItemView { } } - async pushSelected(): Promise { - await this.runBatchOperation('selected', 'push'); - } - - async pullSelected(): Promise { - await this.runBatchOperation('selected', 'pull'); - } - async deleteSelected(): Promise { const targets = this.getSelectedTargets(); if (targets.length === 0) return; const { local, remote } = this.partitionTargets(targets); if (local.length === 0 && remote.length === 0) { new Notice('Nothing to delete'); return; } - if (!await this.confirmDeletion(local.length, remote.length)) return; const total = local.length + remote.length; @@ -775,7 +457,10 @@ export class SyncStatusView extends ItemView { await this.performRemoteDeletion(remote, total, local.length, prog, errors); prog.hide(); - this.notifyDeletionResults(total, errors.length); + new Notice(errors.length > 0 + ? `Deleted ${total - errors.length}/${total}. ${errors.length} failed.` + : `Deleted ${total} files` + ); this.renderView(); } @@ -788,7 +473,7 @@ export class SyncStatusView extends ItemView { private partitionTargets(targets: FileStatus[]) { return { - local: targets.filter(s => s.status !== 'remote-only'), + local: targets.filter(s => s.status !== 'remote-only'), remote: targets.filter(s => s.status === 'remote-only') }; } @@ -798,8 +483,7 @@ export class SyncStatusView extends ItemView { if (localCount > 0 && remoteCount > 0) msg = `Delete ${localCount} local + ${remoteCount} remote file(s)? Cannot be undone.`; else if (localCount > 0) msg = `Delete ${localCount} local file(s)? Cannot be undone.`; else msg = `Delete ${remoteCount} remote file(s)? Cannot be undone.`; - - return await this.showConfirmDialog(msg); + return this.showConfirmDialog(msg); } private async performLocalDeletion(local: FileStatus[], total: number, prog: Notice, errors: string[]): Promise { @@ -829,25 +513,11 @@ export class SyncStatusView extends ItemView { } } - private notifyDeletionResults(total: number, errorCount: number): void { - new Notice(errorCount > 0 - ? `Deleted ${total - errorCount}/${total}. ${errorCount} failed.` - : `Deleted ${total} files` - ); - } - - onClose(): Promise { - return Promise.resolve(); - } + onClose(): Promise { return Promise.resolve(); } private showConfirmDialog(message: string): Promise { return new Promise(resolve => { - new ConfirmModal( - this.app, - message, - () => resolve(true), - () => resolve(false) - ).open(); + new ConfirmModal(this.app, message, () => resolve(true), () => resolve(false)).open(); }); } } diff --git a/src/ui/components/ActionBar.ts b/src/ui/components/ActionBar.ts new file mode 100644 index 0000000..486e19d --- /dev/null +++ b/src/ui/components/ActionBar.ts @@ -0,0 +1,57 @@ +import { setTooltip } from 'obsidian'; + +export interface ActionBarProps { + hasFiles: boolean; + allSelected: boolean; + indeterminate: boolean; + canPush: number; + canPull: number; + canDelete: number; +} + +export interface ActionBarCallbacks { + onRefresh: () => void; + onSelectAll: (select: boolean) => void; + onPush: () => void; + onPull: () => void; + onDelete: () => void; +} + +export function renderActionBar(container: HTMLElement, props: ActionBarProps, callbacks: ActionBarCallbacks): void { + const bar = container.createDiv({ cls: 'ssv-action-bar' }); + renderRefreshButton(bar, callbacks.onRefresh); + + 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); + } +} + +function renderRefreshButton(bar: HTMLElement, onRefresh: () => void): void { + const btn = bar.createEl('button', { cls: 'ssv-btn ssv-btn-refresh' }); + btn.createSpan({ text: '↻' }); + btn.createSpan({ cls: 'ssv-btn-label', text: ' Refresh' }); + setTooltip(btn, 'Refresh all statuses'); + btn.addEventListener('click', onRefresh); +} + +function renderSelectAllRow(bar: HTMLElement, allSelected: boolean, indeterminate: boolean, onSelectAll: (select: boolean) => void): void { + const selectRow = bar.createDiv({ cls: 'ssv-select-row' }); + const cb = selectRow.createEl('input', { type: 'checkbox' }); + cb.checked = allSelected; + cb.indeterminate = indeterminate; + selectRow.createSpan({ cls: 'ssv-select-label', text: 'Select' }); + cb.addEventListener('change', () => onSelectAll(cb.checked)); +} + +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 }); + btn.createSpan({ cls: 'ssv-btn-label', text: label }); + btn.disabled = disabled; + setTooltip(btn, tooltip); + btn.addEventListener('click', onClick); +} diff --git a/src/ui/components/DiffPanel.ts b/src/ui/components/DiffPanel.ts new file mode 100644 index 0000000..816a836 --- /dev/null +++ b/src/ui/components/DiffPanel.ts @@ -0,0 +1,31 @@ +import { computeSideBySideDiff, type DiffSide } from '../../utils/diff'; + +export function renderDiffPanel(fileEl: HTMLElement, remoteContent: string, localContent: string): HTMLElement { + const diffEl = fileEl.createDiv({ cls: 'ssv-diff' }); + const rows = computeSideBySideDiff(remoteContent, localContent); + + const grid = diffEl.createDiv({ cls: 'ssv-diff-split' }).createDiv({ cls: 'ssv-diff-grid' }); + grid.createDiv({ cls: 'ssv-diff-hd', text: 'Remote' }); + grid.createDiv({ cls: 'ssv-diff-hd', text: 'Local' }); + for (const row of rows) { + renderDiffCell(grid, row.left); + renderDiffCell(grid, row.right); + } + + const unifiedEl = diffEl.createEl('pre', { cls: 'ssv-diff-unified' }); + for (const { left, right } of rows) { + if (left.type === 'removed') unifiedEl.createSpan({ cls: 'ssv-u-line removed' }).textContent = `- ${left.content ?? ''}\n`; + if (right.type === 'added') unifiedEl.createSpan({ cls: 'ssv-u-line added' }).textContent = `+ ${right.content ?? ''}\n`; + if (left.type === 'unchanged') unifiedEl.createSpan({ cls: 'ssv-u-line unchanged' }).textContent = ` ${left.content ?? ''}\n`; + } + + return diffEl; +} + +function renderDiffCell(grid: HTMLElement, side: DiffSide): void { + const cell = grid.createDiv({ cls: `ssv-diff-cell ${side.type}` }); + cell.createSpan({ cls: 'ssv-diff-ln' }).textContent = side.lineNum === null ? '' : String(side.lineNum); + if (side.content !== null) { + cell.createSpan({ cls: 'ssv-diff-code' }).textContent = side.content; + } +} diff --git a/src/ui/components/FileListItem.ts b/src/ui/components/FileListItem.ts new file mode 100644 index 0000000..979791e --- /dev/null +++ b/src/ui/components/FileListItem.ts @@ -0,0 +1,88 @@ +import { setTooltip } from 'obsidian'; +import { type FileStatus } from '../types'; +import { renderDiffPanel } from './DiffPanel'; + +export interface FileItemCallbacks { + onSelect: (path: string, selected: boolean) => void; + onPush: (fileStatus: FileStatus) => void; + onPull: (fileStatus: FileStatus) => void; + onDelete: (fileStatus: FileStatus) => void; +} + +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' }; + } +} + +export function renderFileItem( + container: HTMLElement, + fileStatus: FileStatus, + isSelected: boolean, + callbacks: FileItemCallbacks +): void { + const { icon, label, iconCls, badgeCls, fileCls } = statusMeta(fileStatus.status); + const fileEl = container.createDiv({ cls: `ssv-file ${fileCls}` }); + const row = fileEl.createDiv({ cls: 'ssv-file-row' }); + + const cb = row.createEl('input', { type: 'checkbox', cls: 'ssv-file-checkbox' }); + cb.checked = isSelected; + cb.addEventListener('change', () => callbacks.onSelect(fileStatus.path, cb.checked)); + + row.createSpan({ cls: `ssv-file-icon ${iconCls}`, text: icon }); + row.createSpan({ cls: 'ssv-file-path', text: fileStatus.path }); + row.createSpan({ cls: `ssv-status-badge ${badgeCls}`, text: label }); + + if (fileStatus.status !== 'synced' && fileStatus.status !== 'checking') { + renderFileActions(fileEl, fileStatus, callbacks); + } +} + +function renderFileActions(fileEl: HTMLElement, fileStatus: FileStatus, callbacks: FileItemCallbacks): void { + const actions = fileEl.createDiv({ cls: 'ssv-file-actions' }); + + if (fileStatus.status === 'modified' && fileStatus.diff) { + renderDiffToggleButton(actions, fileEl, fileStatus); + } + + if ((fileStatus.status === 'modified' || fileStatus.status === 'unsynced') && fileStatus.file) { + renderActionBtn(actions, '↑', ' 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'); + } + + if (fileStatus.status === 'unsynced' && fileStatus.file) { + renderActionBtn(actions, '✕', ' 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 btnLabel = diffBtn.createSpan({ cls: 'ssv-btn-label', text: ' Diff' }); + const diffEl = renderDiffPanel(fileEl, fileStatus.remoteContent ?? '', fileStatus.localContent ?? ''); + setTooltip(diffBtn, 'Toggle diff view'); + diffBtn.addEventListener('click', () => { + 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 ? '≡' : '▴'; + } + }); +} + +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 }); + btn.createSpan({ cls: 'ssv-btn-label', text: label }); + setTooltip(btn, tooltip); + btn.addEventListener('click', onClick); +} diff --git a/src/ui/types.ts b/src/ui/types.ts new file mode 100644 index 0000000..a472b17 --- /dev/null +++ b/src/ui/types.ts @@ -0,0 +1,13 @@ +import { TFile } from 'obsidian'; + +export interface FileStatus { + file?: TFile; + path: string; + status: 'synced' | 'modified' | 'unsynced' | 'remote-only' | 'checking'; + localContent?: string; + remoteContent?: string; + remoteSha?: string; + diff?: string; +} + +export type FilterValue = 'all' | 'synced' | 'modified' | 'unsynced' | 'remote-only'; diff --git a/src/utils/diff.ts b/src/utils/diff.ts new file mode 100644 index 0000000..422b1fc --- /dev/null +++ b/src/utils/diff.ts @@ -0,0 +1,154 @@ +export interface DiffSide { + lineNum: number | null; + content: string | null; + type: 'removed' | 'added' | 'unchanged' | 'empty'; +} + +export interface DiffRow { + left: DiffSide; + right: DiffSide; +} + +type DiffOpType = 'unchanged' | 'removed' | 'added'; + +interface DiffOp { + type: DiffOpType; + li: number; + ri: number; +} + +export function computeSideBySideDiff(remote: string, local: string): DiffRow[] { + const L = normalizeContent(remote).split('\n'); + const R = normalizeContent(local).split('\n'); + const m = L.length, n = R.length; + + if (m * n > 250_000 || (m + 1) * (n + 1) > 1_000_000) { + return simpleDiff(L, R); + } + + const dp = buildDPMatrix(L, R, m, n); + const ops = tracePath(L, R, dp, m, n); + return pairDiffOps(ops, L, R); +} + +function normalizeContent(s: string): string { + return s.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); +} + +function buildDPMatrix(L: string[], R: string[], m: number, n: number): Uint32Array { + const W = n + 1; + const dp = new Uint32Array((m + 1) * W); + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + dp[i * W + j] = L[i - 1] === R[j - 1] + ? (dp[(i - 1) * W + (j - 1)]!) + 1 + : Math.max(dp[(i - 1) * W + j]!, dp[i * W + (j - 1)]!); + } + } + return dp; +} + +function tracePath(L: string[], R: string[], dp: Uint32Array, m: number, n: number): DiffOp[] { + const W = n + 1; + const ops: DiffOp[] = []; + let i = m, j = n; + while (i > 0 || j > 0) { + const op = getNextDiffOp(L, R, dp, W, i, j); + ops.push(op); + [i, j] = updateIndices(op, i, j); + } + return ops.reverse(); +} + +function updateIndices(op: DiffOp, i: number, j: number): [number, number] { + if (op.type === 'unchanged') return [i - 1, j - 1]; + if (op.type === 'added') return [i, j - 1]; + return [i - 1, j]; +} + +function getNextDiffOp(L: string[], R: string[], dp: Uint32Array, W: number, i: number, j: number): DiffOp { + if (i > 0 && j > 0 && L[i - 1] === R[j - 1]) { + return { type: 'unchanged', li: i - 1, ri: j - 1 }; + } + + const canAdd = j > 0; + const preferAdd = canAdd && (i === 0 || dp[i * W + (j - 1)]! >= dp[(i - 1) * W + j]!); + + if (preferAdd) { + return { type: 'added', li: -1, ri: j - 1 }; + } + return { type: 'removed', li: i - 1, ri: -1 }; +} + +function pairDiffOps(ops: DiffOp[], L: string[], R: string[]): DiffRow[] { + const rows: DiffRow[] = []; + let k = 0; + while (k < ops.length) { + const op = ops[k]; + if (!op) break; + + if (op.type === 'unchanged') { + rows.push(createUnchangedRow(op, L, R)); + k++; + } else { + const batch = collectChangeBatch(ops, k); + rows.push(...createChangeRows(batch, L, R)); + k += batch.length; + } + } + return rows; +} + +function createUnchangedRow(op: DiffOp, L: string[], R: string[]): DiffRow { + return { + left: { lineNum: op.li + 1, content: L[op.li] ?? null, type: 'unchanged' }, + right: { lineNum: op.ri + 1, content: R[op.ri] ?? null, type: 'unchanged' }, + }; +} + +function collectChangeBatch(ops: DiffOp[], startIdx: number): DiffOp[] { + const batch: DiffOp[] = []; + let k = startIdx; + while (k < ops.length) { + const item = ops[k]; + if (!item || item.type === 'unchanged') break; + batch.push(item); + k++; + } + return batch; +} + +function createChangeRows(batch: DiffOp[], L: string[], R: string[]): DiffRow[] { + const removedIdxs = batch.filter(o => o.type === 'removed').map(o => o.li); + const addedIdxs = batch.filter(o => o.type === 'added').map(o => o.ri); + const len = Math.max(removedIdxs.length, addedIdxs.length); + const rows: DiffRow[] = []; + + for (let x = 0; x < len; x++) { + rows.push({ + left: createDiffSide(removedIdxs[x], L, 'removed'), + right: createDiffSide(addedIdxs[x], R, 'added') + }); + } + return rows; +} + +function createDiffSide(idx: number | undefined, lines: string[], type: 'removed' | 'added'): DiffSide { + if (idx === undefined) { + return { lineNum: null, content: null, type: 'empty' }; + } + return { lineNum: idx + 1, content: lines[idx] ?? null, type }; +} + +function simpleDiff(L: string[], R: string[]): DiffRow[] { + const rows: DiffRow[] = []; + const max = Math.max(L.length, R.length); + for (let i = 0; i < max; i++) { + const l = L[i], r = R[i]; + if (l === undefined) rows.push({ left: { lineNum: null, content: null, type: 'empty' }, right: { lineNum: i + 1, content: r ?? null, type: 'added' } }); + else if (r === undefined) rows.push({ left: { lineNum: i + 1, content: l, type: 'removed' }, right: { lineNum: null, content: null, type: 'empty' } }); + else if (l === r) rows.push({ left: { lineNum: i + 1, content: l, type: 'unchanged' }, right: { lineNum: i + 1, content: r, type: 'unchanged' } }); + else rows.push({ left: { lineNum: i + 1, content: l, type: 'removed' }, right: { lineNum: i + 1, content: r, type: 'added' } }); + } + return rows; +} diff --git a/src/utils/logger.ts b/src/utils/logger.ts new file mode 100644 index 0000000..2ccd5eb --- /dev/null +++ b/src/utils/logger.ts @@ -0,0 +1,6 @@ +const PREFIX = '[git-file-sync]'; + +export const logger = { + error: (message: string, ...args: unknown[]) => console.error(`${PREFIX} ${message}`, ...args), + warn: (message: string, ...args: unknown[]) => console.warn(`${PREFIX} ${message}`, ...args), +}; diff --git a/styles.css b/styles.css index 6c28f70..73075c9 100644 --- a/styles.css +++ b/styles.css @@ -10,7 +10,6 @@ container-type: inline-size; } -.hidden { display: none !important; } /* ── Info strip ─────────────────────────────────────────────────── */ .ssv-info { @@ -141,10 +140,6 @@ } /* ── Mobile specific overrides ─────────────────────────────────── */ -.is-mobile .ssv-btn-label, -.is-mobile .ssv-tab-label { - display: inline !important; -} .is-mobile .ssv-btn { padding: 8px 14px; @@ -369,9 +364,6 @@ min-height: 40px; } -.is-mobile .ssv-action-btn .ssv-btn-label { - display: inline !important; -} .ssv-action-btn:hover { @@ -542,6 +534,16 @@ .ssv-diff { padding-left: 0; } } +/* Mobile label overrides — placed after container queries so source order wins */ +.is-mobile .ssv-btn-label, +.is-mobile .ssv-tab-label { + display: inline; +} + +.is-mobile .ssv-action-btn .ssv-btn-label { + display: inline; +} + /* ── Conflict Modal ─────────────────────────────────────────────── */ .sync-conflict-modal { max-width: 900px; } @@ -564,7 +566,7 @@ .conflict-section h3, .conflict-diff-section h3 { - margin: 0 0 8px 0; + margin: 0 0 8px; font-size: 0.9em; font-weight: 600; } @@ -597,7 +599,7 @@ @media (max-width: 480px) { .conflict-buttons { justify-content: stretch; } .conflict-buttons .setting-item-control { flex: 1; display: flex; flex-direction: column; gap: 8px; } - .conflict-buttons button { width: 100%; margin: 0 !important; } + .sync-conflict-modal .conflict-buttons button { width: 100%; margin: 0; } } .conflict-buttons .setting-item { diff --git a/tests/utils/diff.test.ts b/tests/utils/diff.test.ts new file mode 100644 index 0000000..e0a9fa2 --- /dev/null +++ b/tests/utils/diff.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect } from 'vitest'; +import { computeSideBySideDiff } from '../../src/utils/diff'; + +describe('computeSideBySideDiff', () => { + describe('identical content', () => { + it('returns all unchanged rows for identical single-line content', () => { + const rows = computeSideBySideDiff('hello', 'hello'); + expect(rows).toHaveLength(1); + expect(rows[0]).toEqual({ + left: { lineNum: 1, content: 'hello', type: 'unchanged' }, + right: { lineNum: 1, content: 'hello', type: 'unchanged' }, + }); + }); + + it('returns all unchanged rows for identical multi-line content', () => { + const text = 'line1\nline2\nline3'; + const rows = computeSideBySideDiff(text, text); + expect(rows).toHaveLength(3); + rows.forEach(row => { + expect(row.left.type).toBe('unchanged'); + expect(row.right.type).toBe('unchanged'); + }); + }); + + it('handles empty strings', () => { + const rows = computeSideBySideDiff('', ''); + expect(rows).toHaveLength(1); + expect(rows[0]!.left.type).toBe('unchanged'); + }); + }); + + describe('CRLF normalisation', () => { + it('treats CRLF and LF as identical', () => { + const rows = computeSideBySideDiff('a\r\nb', 'a\nb'); + expect(rows).toHaveLength(2); + rows.forEach(row => expect(row.left.type).toBe('unchanged')); + }); + + it('treats CR-only line endings as identical', () => { + const rows = computeSideBySideDiff('a\rb', 'a\nb'); + expect(rows).toHaveLength(2); + rows.forEach(row => expect(row.left.type).toBe('unchanged')); + }); + }); + + describe('additions only (remote → local adds lines)', () => { + it('detects a single added line with empty phantom left side', () => { + // 'a' is unchanged; 'b' is a pure add — no corresponding removed line → left side is empty + const rows = computeSideBySideDiff('a', 'a\nb'); + const added = rows.find(r => r.right.type === 'added'); + expect(added).toBeDefined(); + expect(added!.right.content).toBe('b'); + expect(added!.left.type).toBe('empty'); + expect(added!.left.lineNum).toBeNull(); + }); + + it('detects multiple added lines', () => { + const rows = computeSideBySideDiff('a', 'a\nb\nc'); + const addedRows = rows.filter(r => r.right.type === 'added'); + expect(addedRows).toHaveLength(2); + expect(addedRows.map(r => r.right.content)).toEqual(['b', 'c']); + }); + + it('treats single-line change as removed+added, not empty+added', () => { + // Both sides have exactly one (different) line — no phantom empty side + const rows = computeSideBySideDiff('old', 'new'); + expect(rows).toHaveLength(1); + expect(rows[0]!.left.type).toBe('removed'); + expect(rows[0]!.right.type).toBe('added'); + }); + }); + + describe('removals only (remote has extra lines vs local)', () => { + it('detects a single removed line with empty phantom right side', () => { + // 'a' is unchanged; 'b' is a pure remove — no corresponding added line → right side is empty + const rows = computeSideBySideDiff('a\nb', 'a'); + const removed = rows.find(r => r.left.type === 'removed'); + expect(removed).toBeDefined(); + expect(removed!.left.content).toBe('b'); + expect(removed!.right.type).toBe('empty'); + expect(removed!.right.lineNum).toBeNull(); + }); + + it('detects multiple removed lines', () => { + const rows = computeSideBySideDiff('a\nb\nc', 'a'); + const removedRows = rows.filter(r => r.left.type === 'removed'); + expect(removedRows).toHaveLength(2); + expect(removedRows.map(r => r.left.content)).toEqual(['b', 'c']); + }); + }); + + describe('mixed changes', () => { + it('correctly pairs removed and added lines', () => { + const rows = computeSideBySideDiff('old line', 'new line'); + expect(rows).toHaveLength(1); + expect(rows[0]!.left.type).toBe('removed'); + expect(rows[0]!.left.content).toBe('old line'); + expect(rows[0]!.right.type).toBe('added'); + expect(rows[0]!.right.content).toBe('new line'); + }); + + it('preserves unchanged lines between changes', () => { + const remote = 'header\nold body\nfooter'; + const local = 'header\nnew body\nfooter'; + const rows = computeSideBySideDiff(remote, local); + + const unchanged = rows.filter(r => r.left.type === 'unchanged'); + expect(unchanged).toHaveLength(2); + expect(unchanged[0]!.left.content).toBe('header'); + expect(unchanged[1]!.left.content).toBe('footer'); + + const changed = rows.find(r => r.left.type === 'removed'); + expect(changed!.left.content).toBe('old body'); + expect(changed!.right.content).toBe('new body'); + }); + }); + + describe('line numbers', () => { + it('assigns correct 1-based line numbers to unchanged rows', () => { + const rows = computeSideBySideDiff('a\nb\nc', 'a\nb\nc'); + rows.forEach((row, i) => { + expect(row.left.lineNum).toBe(i + 1); + expect(row.right.lineNum).toBe(i + 1); + }); + }); + + it('assigns null line number to empty (phantom) sides', () => { + // Pure add: 'a' unchanged, 'b' added with no counterpart on the left + const rows = computeSideBySideDiff('a', 'a\nb'); + const emptyRow = rows.find(r => r.left.type === 'empty'); + expect(emptyRow).toBeDefined(); + expect(emptyRow!.left.lineNum).toBeNull(); + expect(emptyRow!.left.content).toBeNull(); + }); + }); + + describe('large file fallback (simpleDiff)', () => { + it('falls back to simpleDiff for files exceeding LCS threshold', () => { + // Create inputs where m*n > 250_000 to trigger simpleDiff + const longRemote = Array.from({ length: 600 }, (_, i) => `remote line ${i}`).join('\n'); + const longLocal = Array.from({ length: 600 }, (_, i) => `local line ${i}`).join('\n'); + + const rows = computeSideBySideDiff(longRemote, longLocal); + expect(rows).toHaveLength(600); + // simpleDiff compares line-by-line; all differ here + rows.forEach(row => { + expect(row.left.type).toBe('removed'); + expect(row.right.type).toBe('added'); + }); + }); + + it('simpleDiff handles remote shorter than local', () => { + const longRemote = Array.from({ length: 600 }, () => 'same').join('\n'); + const longLocal = Array.from({ length: 700 }, () => 'same').join('\n'); + + const rows = computeSideBySideDiff(longRemote, longLocal); + expect(rows).toHaveLength(700); + + // First 600 rows: identical content → unchanged + expect(rows[0]!.left.type).toBe('unchanged'); + // Last 100 rows: added on the right + const extraRows = rows.slice(600); + extraRows.forEach(row => { + expect(row.left.type).toBe('empty'); + expect(row.right.type).toBe('added'); + }); + }); + + it('simpleDiff handles local shorter than remote', () => { + const longRemote = Array.from({ length: 700 }, () => 'same').join('\n'); + const longLocal = Array.from({ length: 600 }, () => 'same').join('\n'); + + const rows = computeSideBySideDiff(longRemote, longLocal); + expect(rows).toHaveLength(700); + + const extraRows = rows.slice(600); + extraRows.forEach(row => { + expect(row.left.type).toBe('removed'); + expect(row.right.type).toBe('empty'); + }); + }); + }); +});