diff --git a/src/services/git-service-base.ts b/src/services/git-service-base.ts index 022d4ca..91d34d3 100644 --- a/src/services/git-service-base.ts +++ b/src/services/git-service-base.ts @@ -23,6 +23,7 @@ export interface GitHubTreeItem { path: string; type: string; mode?: string; + sha?: string; } export interface GitHubTreeResponse { @@ -41,6 +42,8 @@ export interface GitLabTreeItem { path: string; type: string; mode?: string; + /** GitLab's tree API calls the blob SHA "id", not "sha". */ + id?: string; } export interface ConnectionTestResult { @@ -199,6 +202,16 @@ export abstract class BaseGitService { return this.isBinary(path) ? bytes.buffer : new TextDecoder().decode(bytes); } + /** + * Fetches a blob by SHA from a GitHub-shaped git data API (GitHub and Gitea + * both expose `GET .../git/blobs/{sha}` returning base64 `content`). + */ + protected async fetchGitHubStyleBlob(url: string, path: string): Promise { + const response = await this.safeRequest(url, 'GET'); + const data = this.parseJson<{ content: string; encoding: string; sha: string }>(response); + return { content: this.decodeContent(data.content, path), sha: data.sha }; + } + protected handleFileNotFound(e: unknown): GitFile { if (e instanceof Error && e.message.includes('404')) { return { content: '', sha: '' }; diff --git a/src/services/git-service-interface.ts b/src/services/git-service-interface.ts index 2ff6ac3..b1518ec 100644 --- a/src/services/git-service-interface.ts +++ b/src/services/git-service-interface.ts @@ -13,6 +13,13 @@ export interface GitFile { export interface GitTreeEntry { path: string; symlink: boolean; + /** + * The blob's git SHA, when the provider's tree listing includes it. Lets a + * refresh classify sync status by comparing a locally-computed blob SHA + * against this, instead of fetching full file content per entry. Absent + * entries fall back to a content-based comparison via getFile. + */ + sha?: string; } export interface GitServiceInterface { @@ -32,4 +39,11 @@ export interface GitServiceInterface { pushSymlink?(path: string, target: string, branch: string, commitMessage: string): Promise<{ path: string, sha?: string }>; deleteFile(path: string, branch: string, commitMessage: string): Promise; getRepoGitignores(branch: string): Promise; + /** + * Fetches a blob's content directly by its git SHA (from a GitTreeEntry), + * bypassing the path/ref-based Contents API. Used to lazily load a modified + * file's remote content (e.g. to render a diff) once a SHA-based refresh has + * already determined it differs from the local copy. + */ + getBlob(sha: string, path: string): Promise; } diff --git a/src/services/gitea-service.ts b/src/services/gitea-service.ts index 3ef8404..05460c6 100644 --- a/src/services/gitea-service.ts +++ b/src/services/gitea-service.ts @@ -77,7 +77,7 @@ export class GiteaService extends BaseGitService implements GitServiceInterface const entries = treeData.tree .filter(item => item.type === 'blob') - .map(item => ({ path: item.path, symlink: item.mode === GIT_SYMLINK_MODE })); + .map(item => ({ path: item.path, symlink: item.mode === GIT_SYMLINK_MODE, sha: item.sha })); if (!useFilter) return entries; @@ -88,6 +88,10 @@ export class GiteaService extends BaseGitService implements GitServiceInterface }); } + async getBlob(sha: string, path: string): Promise { + return this.fetchGitHubStyleBlob(`${this.baseUrl}/api/v1/repos/${this.owner}/${this.repo}/git/blobs/${sha}`, path); + } + async deleteFile(path: string, branch: string, message: string): Promise { const file = await this.getFile(path, branch); const url = this.getApiUrl(path); diff --git a/src/services/github-service.ts b/src/services/github-service.ts index a320e16..de0ca48 100644 --- a/src/services/github-service.ts +++ b/src/services/github-service.ts @@ -111,7 +111,7 @@ export class GitHubService extends BaseGitService implements GitServiceInterface const entries = data.tree .filter(item => item.type === 'blob') - .map(item => ({ path: item.path, symlink: item.mode === GIT_SYMLINK_MODE })); + .map(item => ({ path: item.path, symlink: item.mode === GIT_SYMLINK_MODE, sha: item.sha })); if (!useFilter) return entries; @@ -122,6 +122,10 @@ export class GitHubService extends BaseGitService implements GitServiceInterface }); } + async getBlob(sha: string, path: string): Promise { + return this.fetchGitHubStyleBlob(`https://api.github.com/repos/${this.owner}/${this.repo}/git/blobs/${sha}`, path); + } + async deleteFile(path: string, branch: string, message: string): Promise { const file = await this.getFile(path, branch); const url = this.getApiUrl(path); diff --git a/src/services/gitlab-service.ts b/src/services/gitlab-service.ts index 8c5a900..8d0241d 100644 --- a/src/services/gitlab-service.ts +++ b/src/services/gitlab-service.ts @@ -75,7 +75,7 @@ export class GitLabService extends BaseGitService implements GitServiceInterface const entries = data .filter(item => item.type === 'blob') - .map(item => ({ path: item.path, symlink: item.mode === GIT_SYMLINK_MODE })); + .map(item => ({ path: item.path, symlink: item.mode === GIT_SYMLINK_MODE, sha: item.id })); if (useFilter) { const filtered = entries.filter(e => { @@ -95,6 +95,16 @@ export class GitLabService extends BaseGitService implements GitServiceInterface return allEntries; } + async getBlob(sha: string, path: string): Promise { + // Unlike GitHub/Gitea's base64-JSON blob endpoint, GitLab's raw blob + // endpoint returns the file's actual bytes directly. + const encodedProjectId = encodeURIComponent(this.projectId); + const url = `${this.baseUrl}/api/v4/projects/${encodedProjectId}/repository/blobs/${sha}/raw`; + const response = await this.safeRequest(url, 'GET'); + const content = this.isBinary(path) ? response.arrayBuffer : response.text; + return { content, sha }; + } + async deleteFile(path: string, branch: string, message: string): Promise { const url = this.getApiUrl(path); const body = { diff --git a/src/ui/SyncStatusView.ts b/src/ui/SyncStatusView.ts index 232847d..2ab31f7 100644 --- a/src/ui/SyncStatusView.ts +++ b/src/ui/SyncStatusView.ts @@ -1,6 +1,6 @@ import { ItemView, WorkspaceLeaf, TFile, Notice, Platform, setIcon, setTooltip } from 'obsidian'; import GitLabFilesPush from '../main'; -import { getServiceName, getEffectiveSymlinkHandling } from '../settings'; +import { getServiceName, getEffectiveSymlinkHandling, type SymlinkHandling } from '../settings'; import { ConfirmModal } from './ConfirmModal'; import { logger } from '../utils/logger'; import { type FileStatus, type FilterValue } from './types'; @@ -9,6 +9,8 @@ import { renderFileItem, statusMeta, type FileItemCallbacks } from './components import { ICONS } from './components/icons'; import { isBinaryPath, contentsEqual } from '../utils/path'; import { readLocalSymlinkTarget } from '../utils/symlink'; +import { gitBlobSha } from '../utils/git-blob-sha'; +import { type GitTreeEntry } from '../services/git-service-interface'; export const SYNC_STATUS_VIEW_TYPE = 'sync-status-view'; @@ -203,9 +205,26 @@ export class SyncStatusView extends ItemView { onPush: (fs) => void this.runSingleFile(fs, 'push'), onPull: (fs) => void this.runSingleFile(fs, 'pull'), onDelete: (fs) => void this.handleLocalDelete(fs), + onExpandDiff: (fs) => this.loadDiffContent(fs), }; } + /** + * Lazily fetches a modified file's remote content by SHA (Phase 2 of the + * SHA-based refresh) so the diff panel has something to render. Mutates + * the FileStatus object in place, caching the result on the same instance + * held in this.fileStatuses so re-expanding doesn't refetch. + */ + private async loadDiffContent(fileStatus: FileStatus): Promise { + if (fileStatus.remoteContent !== undefined || !fileStatus.remoteSha) return; + try { + const blob = await this.plugin.gitService.getBlob(fileStatus.remoteSha, fileStatus.path); + fileStatus.remoteContent = blob.content; + } catch (e) { + logger.warn(`Failed to load diff content for ${fileStatus.path}`, e); + } + } + private renderFileList(container: HTMLElement): void { const all = Array.from(this.fileStatuses.values()); const statuses = this.statusFilter === 'all' @@ -255,11 +274,11 @@ export class SyncStatusView extends ItemView { } await new Promise(r => window.setTimeout(r, 500)); - await this.refreshFileStatus(fileStatus.file || fileStatus.path); + await this.refreshFileStatus(fileStatus.file || fileStatus.path, undefined); this.renderView(); } catch (e) { new Notice(`${op === 'push' ? 'Push' : 'Pull'} failed: ${e instanceof Error ? e.message : String(e)}`); - await this.refreshFileStatus(fileStatus.file || fileStatus.path); + await this.refreshFileStatus(fileStatus.file || fileStatus.path, undefined); this.renderView(); } } @@ -289,7 +308,7 @@ export class SyncStatusView extends ItemView { this.renderView(); const filesToCheck = this.getCheckableFiles(files.local, extra, files.hiddenLocalPaths); - await this.performStatusCheck(filesToCheck); + await this.performStatusCheck(filesToCheck, files.remoteMap); this.lastSyncTime = Date.now(); this.isRefreshing = false; // Set to false BEFORE final renderView @@ -310,7 +329,7 @@ export class SyncStatusView extends ItemView { await this.plugin.gitignoreManager.loadGitignores(); // Map remote paths to vault paths - const remoteMap = new Map(); // vaultPath -> remoteFullPath + const remoteMap = new Map(); // vaultPath -> tree entry (path, symlink, sha) const skipSymlinks = getEffectiveSymlinkHandling(this.plugin.settings) === 'skip'; for (const entry of remoteEntries) { if (entry.symlink && skipSymlinks) continue; // Symlink handling: skip @@ -319,7 +338,7 @@ export class SyncStatusView extends ItemView { const vaultPath = this.plugin.getVaultPath(normalized); if (!this.plugin.gitignoreManager.isIgnored(normalized)) { - remoteMap.set(vaultPath, entry.path); + remoteMap.set(vaultPath, entry); } } @@ -394,7 +413,7 @@ export class SyncStatusView extends ItemView { } } - private async identifyExtraFiles(remoteMap: Map, localFilePaths: Set, allLocalFileMap: Map) { + private async identifyExtraFiles(remoteMap: Map, localFilePaths: Set, allLocalFileMap: Map) { const extra: Array = []; for (const [vaultPath] of remoteMap.entries()) { if (localFilePaths.has(vaultPath)) continue; @@ -440,7 +459,7 @@ export class SyncStatusView extends ItemView { private static readonly STATUS_CHECK_CONCURRENCY = 8; private static readonly RENDER_THROTTLE_MS = 150; - private async performStatusCheck(filesToCheck: Array): Promise { + private async performStatusCheck(filesToCheck: Array, remoteMap: Map): Promise { const total = filesToCheck.length; this.refreshProgress = { current: 0, total }; @@ -457,7 +476,10 @@ export class SyncStatusView extends ItemView { const worker = async (): Promise => { while (next < total) { const file = filesToCheck[next++]; - if (file) await this.refreshFileStatus(file); + if (file) { + const path = typeof file === 'string' ? file : file.path; + await this.refreshFileStatus(file, remoteMap.get(path)); + } this.refreshProgress.current++; maybeRender(); } @@ -468,22 +490,20 @@ export class SyncStatusView extends ItemView { maybeRender(true); } - private async refreshFileStatus(fileOrPath: TFile | string): Promise { + /** + * Classifies a file's sync status. When the remote tree entry carries a git + * blob SHA (the common case), this is a single local hash + comparison with + * no network request (Phase 1 of the SHA-based refresh). Falls back to the + * previous full-content comparison via getFile() when a tree entry is + * missing a SHA, or wasn't found on the remote at all (new local file). + */ + private async refreshFileStatus(fileOrPath: TFile | string, remoteEntry: GitTreeEntry | undefined): Promise { try { - const isStr = typeof fileOrPath === 'string'; - const path = isStr ? fileOrPath : fileOrPath.path; - const file = isStr ? undefined : fileOrPath; - - const binary = this.isBinary(path); - const localContent = await this.readFileContent(fileOrPath, binary, isStr); - - // Important: Use SyncManager's logic which handles rootPath/vaultFolder mapping - const repoPath = this.plugin.getNormalizedPath(path); - const remote = await this.plugin.gitService.getFile(repoPath, this.plugin.settings.branch); - - const { status, diff } = this.determineFileStatus(binary, localContent, remote); - - this.fileStatuses.set(path, { file, path, status, localContent, remoteContent: remote.content, remoteSha: remote.sha, diff }); + if (remoteEntry?.sha !== undefined) { + await this.refreshFileStatusBySha(fileOrPath, remoteEntry); + } else { + await this.refreshFileStatusByContent(fileOrPath); + } } catch (e) { const path = typeof fileOrPath === 'string' ? fileOrPath : fileOrPath.path; logger.warn(`Failed to determine sync status for ${path}`, e); @@ -495,6 +515,60 @@ export class SyncStatusView extends ItemView { } } + private async refreshFileStatusBySha(fileOrPath: TFile | string, remoteEntry: GitTreeEntry): Promise { + const isStr = typeof fileOrPath === 'string'; + const path = isStr ? fileOrPath : fileOrPath.path; + const file = isStr ? undefined : fileOrPath; + const binary = this.isBinary(path); + + const symlinkMode = getEffectiveSymlinkHandling(this.plugin.settings); + const localContent = await this.readLocalContentForSha(fileOrPath, isStr, binary, remoteEntry.symlink, symlinkMode); + const localSha = await gitBlobSha(localContent); + + const status = localSha === remoteEntry.sha ? 'synced' : 'modified'; + this.fileStatuses.set(path, { + file, path, status, localContent, + remoteSha: remoteEntry.sha, + isSymlink: remoteEntry.symlink, + }); + } + + /** + * Determines what to hash locally so it's comparable to the remote blob SHA. + * A symlink's blob content is its target path string, not the content it + * points at, so "real" mode hashes the raw link target instead of following + * it. "follow" mode (and "real" without an actual local OS symlink, e.g. on + * mobile) always hashes the local file's content as read normally. + */ + private async readLocalContentForSha( + fileOrPath: TFile | string, isStr: boolean, binary: boolean, remoteIsSymlink: boolean, symlinkMode: SymlinkHandling + ): Promise { + if (remoteIsSymlink && symlinkMode === 'real') { + const path = typeof fileOrPath === 'string' ? fileOrPath : fileOrPath.path; + const target = readLocalSymlinkTarget(this.app, path); + if (target !== null) return target; + } + return this.readFileContent(fileOrPath, binary, isStr); + } + + /** Fallback status check via full content fetch, for entries without a usable tree SHA. */ + private async refreshFileStatusByContent(fileOrPath: TFile | string): Promise { + const isStr = typeof fileOrPath === 'string'; + const path = isStr ? fileOrPath : fileOrPath.path; + const file = isStr ? undefined : fileOrPath; + + const binary = this.isBinary(path); + const localContent = await this.readFileContent(fileOrPath, binary, isStr); + + // Important: Use SyncManager's logic which handles rootPath/vaultFolder mapping + const repoPath = this.plugin.getNormalizedPath(path); + const remote = await this.plugin.gitService.getFile(repoPath, this.plugin.settings.branch); + + const status = this.determineFileStatus(localContent, remote); + + this.fileStatuses.set(path, { file, path, status, localContent, remoteContent: remote.content, remoteSha: remote.sha }); + } + private async readFileContent(fileOrPath: TFile | string, binary: boolean, isStr: boolean): Promise { if (isStr) { return binary @@ -519,22 +593,10 @@ export class SyncStatusView extends ItemView { throw new Error('Expected TFile when isStr is false'); } - private determineFileStatus(binary: boolean, localContent: string | ArrayBuffer, remote: { sha?: string; content?: string | ArrayBuffer }): { status: FileStatus['status']; diff?: string } { - if (!remote.sha) { - return { status: 'unsynced' }; - } - if (remote.content && this.contentsEqual(localContent, remote.content)) { - return { status: 'synced' }; - } - const diff = this.computeDiff(binary, localContent, remote.content || ''); - return { status: 'modified', diff }; - } - - private computeDiff(binary: boolean, localContent: string | ArrayBuffer, remoteContent: string | ArrayBuffer): string { - if (binary || typeof localContent !== 'string' || typeof remoteContent !== 'string') { - return 'Binary file changed'; - } - return this.generateDiff(remoteContent, localContent); + private determineFileStatus(localContent: string | ArrayBuffer, remote: { sha?: string; content?: string | ArrayBuffer }): FileStatus['status'] { + if (!remote.sha) return 'unsynced'; + if (remote.content && this.contentsEqual(localContent, remote.content)) return 'synced'; + return 'modified'; } private isBinary(path: string): boolean { return isBinaryPath(path); } @@ -543,21 +605,6 @@ export class SyncStatusView extends ItemView { return contentsEqual(a, b); } - private generateDiff(oldContent: string, newContent: string): string { - const oldLines = oldContent.split('\n'); - const newLines = newContent.split('\n'); - const diff = ['--- Remote', '+++ Local', '']; - const maxLines = Math.max(oldLines.length, newLines.length); - for (let i = 0; i < maxLines; i++) { - const o = oldLines[i], n = newLines[i]; - if (o !== n) { - if (o !== undefined) diff.push(`- ${o}`); - if (n !== undefined) diff.push(`+ ${n}`); - } - } - return diff.join('\n'); - } - // ── Batch push/pull/delete ───────────────────────────────────── async pushAllModified(): Promise { await this.runBatchOperation('modified', 'push'); } diff --git a/src/ui/components/DiffPanel.ts b/src/ui/components/DiffPanel.ts index 816a836..4f9f7fb 100644 --- a/src/ui/components/DiffPanel.ts +++ b/src/ui/components/DiffPanel.ts @@ -1,10 +1,10 @@ import { computeSideBySideDiff, type DiffSide } from '../../utils/diff'; -export function renderDiffPanel(fileEl: HTMLElement, remoteContent: string, localContent: string): HTMLElement { - const diffEl = fileEl.createDiv({ cls: 'ssv-diff' }); +/** Renders the side-by-side + unified diff body into an existing container. */ +export function renderDiffPanel(container: HTMLElement, remoteContent: string, localContent: string): void { const rows = computeSideBySideDiff(remoteContent, localContent); - const grid = diffEl.createDiv({ cls: 'ssv-diff-split' }).createDiv({ cls: 'ssv-diff-grid' }); + const grid = container.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) { @@ -12,14 +12,12 @@ export function renderDiffPanel(fileEl: HTMLElement, remoteContent: string, loca renderDiffCell(grid, row.right); } - const unifiedEl = diffEl.createEl('pre', { cls: 'ssv-diff-unified' }); + const unifiedEl = container.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 { diff --git a/src/ui/components/FileListItem.ts b/src/ui/components/FileListItem.ts index 2498de4..18f362f 100644 --- a/src/ui/components/FileListItem.ts +++ b/src/ui/components/FileListItem.ts @@ -8,6 +8,13 @@ export interface FileItemCallbacks { onPush: (fileStatus: FileStatus) => void; onPull: (fileStatus: FileStatus) => void; onDelete: (fileStatus: FileStatus) => void; + /** + * Called the first time a modified file's diff is expanded and its remote + * content hasn't been fetched yet. Must fetch the content, mutate the + * fileStatus object in place (remoteContent, localContent as needed), and + * resolve once it's ready to render. + */ + onExpandDiff: (fileStatus: FileStatus) => Promise; } // `icon` is a Lucide icon id (rendered via Obsidian's setIcon) so every status @@ -48,8 +55,8 @@ export function renderFileItem( 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') { + renderDiffToggleButton(actions, fileEl, fileStatus, callbacks); } if (fileStatus.status === 'modified' || fileStatus.status === 'unsynced') { @@ -65,29 +72,46 @@ function renderFileActions(fileEl: HTMLElement, fileStatus: FileStatus, callback } } -function renderDiffToggleButton(actions: HTMLElement, fileEl: HTMLElement, fileStatus: FileStatus): void { +function renderDiffToggleButton(actions: HTMLElement, fileEl: HTMLElement, fileStatus: FileStatus, callbacks: FileItemCallbacks): void { const diffBtn = actions.createEl('button', { cls: 'ssv-action-btn diff' }); const iconEl = diffBtn.createSpan(); setIcon(iconEl, ICONS.diff); const btnLabel = diffBtn.createSpan({ cls: 'ssv-btn-label', text: ' Diff' }); - - let diffEl: HTMLElement; - if (typeof fileStatus.remoteContent === 'string' && typeof fileStatus.localContent === 'string') { - diffEl = renderDiffPanel(fileEl, fileStatus.remoteContent, fileStatus.localContent); - } else { - diffEl = fileEl.createDiv({ cls: 'ssv-diff' }); - diffEl.createDiv({ cls: 'ssv-diff-binary', text: 'Binary file changed' }); - } - + + const diffEl = fileEl.createDiv({ cls: 'ssv-diff' }); + renderDiffBody(diffEl, fileStatus); + setTooltip(diffBtn, 'Toggle diff view'); diffBtn.addEventListener('click', () => { const open = diffEl.hasClass('visible'); + if (!open && needsContentFetch(fileStatus)) { + diffEl.empty(); + diffEl.createDiv({ cls: 'ssv-diff-loading', text: 'Loading diff…' }); + void callbacks.onExpandDiff(fileStatus).then(() => renderDiffBody(diffEl, fileStatus)); + } diffEl.toggleClass('visible', !open); btnLabel.setText(open ? ' Diff' : ' Hide'); setIcon(iconEl, open ? ICONS.diff : ICONS.diffOpen); }); } +function needsContentFetch(fileStatus: FileStatus): boolean { + return !fileStatus.isSymlink && fileStatus.remoteContent === undefined; +} + +function renderDiffBody(diffEl: HTMLElement, fileStatus: FileStatus): void { + diffEl.empty(); + if (fileStatus.isSymlink) { + diffEl.createDiv({ cls: 'ssv-diff-binary', text: 'Symlink target changed' }); + } else if (typeof fileStatus.remoteContent === 'string' && typeof fileStatus.localContent === 'string') { + renderDiffPanel(diffEl, fileStatus.remoteContent, fileStatus.localContent); + } else if (fileStatus.remoteContent === undefined) { + diffEl.createDiv({ cls: 'ssv-diff-loading', text: 'Click Diff to load…' }); + } else { + diffEl.createDiv({ cls: 'ssv-diff-binary', text: 'Binary file changed' }); + } +} + 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}` }); setIcon(btn.createSpan(), icon); diff --git a/src/ui/types.ts b/src/ui/types.ts index 7c1446f..d47db3e 100644 --- a/src/ui/types.ts +++ b/src/ui/types.ts @@ -7,7 +7,8 @@ export interface FileStatus { localContent?: string | ArrayBuffer; remoteContent?: string | ArrayBuffer; remoteSha?: string; - diff?: string; + /** True when the remote blob is a symbolic link (mode 120000). */ + isSymlink?: boolean; } export type FilterValue = 'all' | 'synced' | 'modified' | 'unsynced' | 'remote-only'; diff --git a/src/utils/git-blob-sha.ts b/src/utils/git-blob-sha.ts new file mode 100644 index 0000000..6934d9d --- /dev/null +++ b/src/utils/git-blob-sha.ts @@ -0,0 +1,21 @@ +/** + * Computes a file's git blob SHA-1 locally: sha1("blob " + byteLength + "\0" + contentBytes). + * This is exactly what `git hash-object` produces, so it can be compared directly + * against a tree entry's SHA to classify sync status without fetching remote + * content. Uses the Web Crypto API (crypto.subtle), available on both desktop + * and mobile. + */ +export async function gitBlobSha(content: string | ArrayBuffer): Promise { + const contentBytes = typeof content === 'string' ? new TextEncoder().encode(content) : new Uint8Array(content); + const header = new TextEncoder().encode(`blob ${contentBytes.byteLength}\0`); + + const combined = new Uint8Array(header.byteLength + contentBytes.byteLength); + combined.set(header, 0); + combined.set(contentBytes, header.byteLength); + + // SHA-1 isn't for security here — it's required because it's git's own + // object-hashing algorithm, so this must match `git hash-object` exactly. + // eslint-disable-next-line sonarjs/hashing + const digest = await crypto.subtle.digest('SHA-1', combined); + return Array.from(new Uint8Array(digest)).map(b => b.toString(16).padStart(2, '0')).join(''); +} diff --git a/tests/services/gitea-service.test.ts b/tests/services/gitea-service.test.ts index 4ce0367..829a43e 100644 --- a/tests/services/gitea-service.test.ts +++ b/tests/services/gitea-service.test.ts @@ -184,6 +184,33 @@ describe('GiteaService', () => { mockRequest({ status: 404, json: { message: 'branch does not exist' }, text: 'branch does not exist' }); await expect(service.listFiles('missing-branch')).rejects.toThrow(/Branch "missing-branch" was not found/); }); + + it('listFilesDetailed includes each blob\'s sha', async () => { + vi.mocked(requestUrl) + .mockResolvedValueOnce({ status: 200, json: { commit: { id: commitSha } } } as unknown as RequestUrlResponse) + .mockResolvedValueOnce({ status: 200, json: { tree: [ + { path: 'file1.md', type: 'blob', sha: 'sha-1' }, + ] } } as unknown as RequestUrlResponse); + expect(await service.listFilesDetailed('main')).toEqual([ + { path: 'file1.md', symlink: false, sha: 'sha-1' }, + ]); + }); + }); + + describe('getBlob', () => { + it('decodes base64 blob content by sha', async () => { + mockRequest({ status: 200, json: { content: btoa('hello world'), encoding: 'base64', sha: 'blob-sha' } }); + const result = await service.getBlob('blob-sha', 'test.md'); + expect(result.content).toBe('hello world'); + expect(result.sha).toBe('blob-sha'); + }); + + it('requests the blob endpoint by sha, not path', async () => { + mockRequest({ status: 200, json: { content: btoa('x'), sha: 'abc123' } }); + await service.getBlob('abc123', 'test.md'); + const call = getLastRequestCall(); + expect(call.url).toBe(`${baseUrl}/api/v1/repos/${owner}/${repo}/git/blobs/abc123`); + }); }); describe('deleteFile', () => { diff --git a/tests/services/github-service.test.ts b/tests/services/github-service.test.ts index 78248e1..1154052 100644 --- a/tests/services/github-service.test.ts +++ b/tests/services/github-service.test.ts @@ -196,6 +196,33 @@ describe('GitHubService', () => { mockRequest({ status: 404, json: { message: 'Not Found' }, text: 'Not Found' }); await expect(service.listFiles('missing-branch')).rejects.toThrow(/Branch "missing-branch" was not found/); }); + + it('listFilesDetailed includes each blob\'s sha', async () => { + mockRequest({ status: 200, json: { tree: [ + { path: 'file1.md', type: 'blob', sha: 'sha-1' }, + { path: 'file2.md', type: 'blob', sha: 'sha-2' }, + ] } }); + expect(await service.listFilesDetailed('main')).toEqual([ + { path: 'file1.md', symlink: false, sha: 'sha-1' }, + { path: 'file2.md', symlink: false, sha: 'sha-2' }, + ]); + }); + }); + + describe('getBlob', () => { + it('decodes base64 blob content by sha', async () => { + mockRequest({ status: 200, json: { content: btoa('hello world'), encoding: 'base64', sha: 'blob-sha' } }); + const result = await service.getBlob('blob-sha', 'test.md'); + expect(result.content).toBe('hello world'); + expect(result.sha).toBe('blob-sha'); + }); + + it('requests the blob endpoint by sha, not path', async () => { + mockRequest({ status: 200, json: { content: btoa('x'), sha: 'abc123' } }); + await service.getBlob('abc123', 'test.md'); + const call = getLastRequestCall(); + expect(call.url).toContain('/git/blobs/abc123'); + }); }); describe('deleteFile', () => { diff --git a/tests/services/gitlab-service.test.ts b/tests/services/gitlab-service.test.ts index 55abb9f..a1028da 100644 --- a/tests/services/gitlab-service.test.ts +++ b/tests/services/gitlab-service.test.ts @@ -148,6 +148,38 @@ describe('GitLabService', () => { mockRequest({ status: 404, json: { message: '404 Branch Not Found' }, text: '404 Branch Not Found' }); await expect(service.listFiles('missing-branch')).rejects.toThrow(/Branch "missing-branch" was not found/); }); + + it('listFilesDetailed maps GitLab\'s "id" field to sha', async () => { + mockRequest({ status: 200, json: [ + { path: 'file1.md', type: 'blob', id: 'id-as-sha-1' }, + ] }); + expect(await service.listFilesDetailed('main')).toEqual([ + { path: 'file1.md', symlink: false, sha: 'id-as-sha-1' }, + ]); + }); + }); + + describe('getBlob', () => { + it('returns the raw text content for a non-binary path', async () => { + mockRequest({ status: 200, text: 'hello world' }); + const result = await service.getBlob('blob-sha', 'test.md'); + expect(result.content).toBe('hello world'); + expect(result.sha).toBe('blob-sha'); + }); + + it('requests the raw blob endpoint by sha', async () => { + mockRequest({ status: 200, text: 'x' }); + await service.getBlob('abc123', 'test.md'); + const call = getLastRequestCall(); + expect(call.url).toBe(`${baseUrl}/api/v4/projects/${projectId}/repository/blobs/abc123/raw`); + }); + + it('returns arrayBuffer content for a binary path', async () => { + const buf = new ArrayBuffer(4); + mockRequest({ status: 200, arrayBuffer: buf }); + const result = await service.getBlob('blob-sha', 'image.png'); + expect(result.content).toBe(buf); + }); }); describe('deleteFile', () => { diff --git a/tests/ui/DiffPanel.test.ts b/tests/ui/DiffPanel.test.ts index eb7410a..e1d44f6 100644 --- a/tests/ui/DiffPanel.test.ts +++ b/tests/ui/DiffPanel.test.ts @@ -11,9 +11,9 @@ describe('renderDiffPanel', () => { container = createContainer(); }); - it('returns the diff element with ssv-diff class', () => { - const el = renderDiffPanel(container, '', ''); - expect(el.classList.contains('ssv-diff')).toBe(true); + it('renders the diff grid into the given container', () => { + renderDiffPanel(container, '', ''); + expect(container.querySelector('.ssv-diff-grid')).not.toBeNull(); }); it('renders Remote and Local column headers', () => { diff --git a/tests/ui/FileListItem.test.ts b/tests/ui/FileListItem.test.ts index acd55dd..d89189b 100644 --- a/tests/ui/FileListItem.test.ts +++ b/tests/ui/FileListItem.test.ts @@ -44,6 +44,7 @@ describe('renderFileItem', () => { onPush: vi.fn(), onPull: vi.fn(), onDelete: vi.fn(), + onExpandDiff: vi.fn().mockResolvedValue(undefined), }; }); @@ -119,33 +120,27 @@ describe('renderFileItem', () => { expect(callbacks.onPull).toHaveBeenCalledWith(fs); }); - it('renders diff toggle button when diff content is present', () => { - const fs = makeFileStatus('modified', { diff: 'some diff', localContent: 'b', remoteContent: 'a' }); + it('renders diff toggle button for any modified file, even without preloaded content', () => { + const fs = makeFileStatus('modified', { file: mockFile }); renderFileItem(container, fs, false, callbacks); expect(container.querySelector('.ssv-action-btn.diff')).not.toBeNull(); }); - it('does not render diff toggle when no diff', () => { - const fs = makeFileStatus('modified', { file: mockFile }); - renderFileItem(container, fs, false, callbacks); - expect(container.querySelector('.ssv-action-btn.diff')).toBeNull(); - }); - it('diff panel is not visible before toggle', () => { - const fs = makeFileStatus('modified', { diff: 'd', localContent: 'b', remoteContent: 'a' }); + const fs = makeFileStatus('modified', { localContent: 'b', remoteContent: 'a' }); renderFileItem(container, fs, false, callbacks); expect(container.querySelector('.ssv-diff')?.classList.contains('visible')).toBe(false); }); it('diff panel becomes visible on first click', () => { - const fs = makeFileStatus('modified', { diff: 'd', localContent: 'b', remoteContent: 'a' }); + const fs = makeFileStatus('modified', { localContent: 'b', remoteContent: 'a' }); renderFileItem(container, fs, false, callbacks); (container.querySelector('.ssv-action-btn.diff') as HTMLButtonElement).click(); expect(container.querySelector('.ssv-diff')?.classList.contains('visible')).toBe(true); }); it('diff panel hides on second click', () => { - const fs = makeFileStatus('modified', { diff: 'd', localContent: 'b', remoteContent: 'a' }); + const fs = makeFileStatus('modified', { localContent: 'b', remoteContent: 'a' }); renderFileItem(container, fs, false, callbacks); const btn = container.querySelector('.ssv-action-btn.diff') as HTMLButtonElement; btn.click(); @@ -154,7 +149,7 @@ describe('renderFileItem', () => { }); it('diff button label toggles between " Diff" and " Hide"', () => { - const fs = makeFileStatus('modified', { diff: 'd', localContent: 'b', remoteContent: 'a' }); + const fs = makeFileStatus('modified', { localContent: 'b', remoteContent: 'a' }); renderFileItem(container, fs, false, callbacks); const btn = container.querySelector('.ssv-action-btn.diff') as HTMLButtonElement; const label = btn.querySelector('.ssv-btn-label') as HTMLElement; @@ -164,6 +159,29 @@ describe('renderFileItem', () => { btn.click(); expect(label.textContent).toBe(' Diff'); }); + + it('renders preloaded diff content immediately without fetching', () => { + const fs = makeFileStatus('modified', { localContent: 'b', remoteContent: 'a' }); + renderFileItem(container, fs, false, callbacks); + (container.querySelector('.ssv-action-btn.diff') as HTMLButtonElement).click(); + expect(container.querySelector('.ssv-diff-grid')).not.toBeNull(); + expect(callbacks.onExpandDiff).not.toHaveBeenCalled(); + }); + + it('shows a loading placeholder and fetches content on demand when not preloaded', () => { + const fs = makeFileStatus('modified', { file: mockFile, remoteSha: 'abc123' }); + renderFileItem(container, fs, false, callbacks); + (container.querySelector('.ssv-action-btn.diff') as HTMLButtonElement).click(); + expect(callbacks.onExpandDiff).toHaveBeenCalledWith(fs); + }); + + it('shows a symlink message instead of a text diff for symlink entries', async () => { + const fs = makeFileStatus('modified', { file: mockFile, remoteSha: 'abc123', isSymlink: true }); + renderFileItem(container, fs, false, callbacks); + (container.querySelector('.ssv-action-btn.diff') as HTMLButtonElement).click(); + expect(container.querySelector('.ssv-diff-binary')?.textContent).toBe('Symlink target changed'); + expect(callbacks.onExpandDiff).not.toHaveBeenCalled(); + }); }); describe('unsynced file', () => { diff --git a/tests/utils/git-blob-sha.test.ts b/tests/utils/git-blob-sha.test.ts new file mode 100644 index 0000000..7061177 --- /dev/null +++ b/tests/utils/git-blob-sha.test.ts @@ -0,0 +1,34 @@ +import { describe, it, expect } from 'vitest'; +import { gitBlobSha } from '../../src/utils/git-blob-sha'; + +// Test vectors verified against `git hash-object --stdin`. +describe('gitBlobSha', () => { + it('matches git for empty content', async () => { + expect(await gitBlobSha('')).toBe('e69de29bb2d1d6434b8b29ae775ad8c2e48c5391'); + }); + + it('matches git for a string with no trailing newline', async () => { + expect(await gitBlobSha('hello world')).toBe('95d09f2b10159347eece71399a7e2e907ea3df4f'); + }); + + it('matches git for a string with a trailing newline', async () => { + expect(await gitBlobSha('hello world\n')).toBe('3b18e512dba79e4c8300dd08aeb37f8e728b8dad'); + }); + + it('matches git for multi-byte UTF-8 content', async () => { + // "café" is 5 bytes in UTF-8 (é is 2 bytes) — verifies byte length, not char length, is used. + expect(await gitBlobSha('café')).toBe('1c2e52cfe7542a64cdea57e5fec2fc1739846c03'); + }); + + it('produces the same SHA for equivalent string and ArrayBuffer content', async () => { + const text = 'hello world'; + const bytes = new TextEncoder().encode(text); + const arrayBuffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength); + + expect(await gitBlobSha(arrayBuffer)).toBe(await gitBlobSha(text)); + }); + + it('produces different SHAs for different content', async () => { + expect(await gitBlobSha('a')).not.toBe(await gitBlobSha('b')); + }); +});