mirror of
https://github.com/firstsun-dev/git-files-sync.git
synced 2026-07-22 17:20:30 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
parent
910305a81c
commit
31ac9189cf
15 changed files with 637 additions and 430 deletions
|
|
@ -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" }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<string, FileStatus> = 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<FilterValue, number> = {
|
||||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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<string, TFile>(allFiles.map(f => [f.path, f]))
|
||||
allMap: new Map<string, TFile>(allFiles.map(f => [f.path, f]))
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -623,8 +319,7 @@ export class SyncStatusView extends ItemView {
|
|||
}
|
||||
|
||||
private getCheckableFiles(local: TFile[], extra: Array<TFile | string>) {
|
||||
const combined: Array<TFile | string> = [...local, ...extra];
|
||||
return combined.filter(f => {
|
||||
return ([...local, ...extra] as Array<TFile | string>).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<void> {
|
||||
await this.runBatchOperation('modified', 'push');
|
||||
}
|
||||
// ── Batch push/pull/delete ─────────────────────────────────────
|
||||
|
||||
async pullAllModified(): Promise<void> {
|
||||
await this.runBatchOperation('modified', 'pull');
|
||||
}
|
||||
async pushAllModified(): Promise<void> { await this.runBatchOperation('modified', 'push'); }
|
||||
async pullAllModified(): Promise<void> { await this.runBatchOperation('modified', 'pull'); }
|
||||
async pushSelected(): Promise<void> { await this.runBatchOperation('selected', 'push'); }
|
||||
async pullSelected(): Promise<void> { await this.runBatchOperation('selected', 'pull'); }
|
||||
|
||||
private async runBatchOperation(filter: 'modified' | 'selected', op: 'push' | 'pull'): Promise<void> {
|
||||
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<void> {
|
||||
await this.runBatchOperation('selected', 'push');
|
||||
}
|
||||
|
||||
async pullSelected(): Promise<void> {
|
||||
await this.runBatchOperation('selected', 'pull');
|
||||
}
|
||||
|
||||
async deleteSelected(): Promise<void> {
|
||||
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<void> {
|
||||
|
|
@ -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<void> {
|
||||
return Promise.resolve();
|
||||
}
|
||||
onClose(): Promise<void> { return Promise.resolve(); }
|
||||
|
||||
private showConfirmDialog(message: string): Promise<boolean> {
|
||||
return new Promise(resolve => {
|
||||
new ConfirmModal(
|
||||
this.app,
|
||||
message,
|
||||
() => resolve(true),
|
||||
() => resolve(false)
|
||||
).open();
|
||||
new ConfirmModal(this.app, message, () => resolve(true), () => resolve(false)).open();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
57
src/ui/components/ActionBar.ts
Normal file
57
src/ui/components/ActionBar.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
31
src/ui/components/DiffPanel.ts
Normal file
31
src/ui/components/DiffPanel.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
88
src/ui/components/FileListItem.ts
Normal file
88
src/ui/components/FileListItem.ts
Normal file
|
|
@ -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);
|
||||
}
|
||||
13
src/ui/types.ts
Normal file
13
src/ui/types.ts
Normal file
|
|
@ -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';
|
||||
154
src/utils/diff.ts
Normal file
154
src/utils/diff.ts
Normal file
|
|
@ -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;
|
||||
}
|
||||
6
src/utils/logger.ts
Normal file
6
src/utils/logger.ts
Normal file
|
|
@ -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),
|
||||
};
|
||||
22
styles.css
22
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 {
|
||||
|
|
|
|||
183
tests/utils/diff.test.ts
Normal file
183
tests/utils/diff.test.ts
Normal file
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue