perf(refresh): use tree blob SHAs to avoid per-file content fetches

Refresh previously fetched each file's remote content via getFile() to
compare against local content — an O(n) network round-trip per file
even after the existing concurrency pool. Implements the two-phase
hybrid design from the issue discussion:

Phase 1 — bulk classify, no content:
- Extend GitTreeEntry with sha, populated from each service's tree
  listing (GitHub/Gitea: item.sha, GitLab: item.id — GitLab's tree API
  calls the blob SHA "id", not "sha").
- Add gitBlobSha(): sha1("blob " + byteLength + "\0" + contentBytes)
  via crypto.subtle, matching `git hash-object` exactly (verified
  against real git-produced SHAs in tests, including a multi-byte
  UTF-8 case to check byte length vs char length).
- SyncStatusView.refreshFileStatus() now compares a locally-computed
  blob SHA against the tree entry's SHA — no getFile call. Falls back
  to the previous content-based comparison per-entry when a tree entry
  has no SHA or isn't found in the tree at all (new local file).
- Symlink-aware hashing per the issue's follow-up note: a symlink's
  blob content is its target path string, not the content it points
  at. "real" mode hashes the raw local link target (readLocalSymlinkTarget)
  instead of following it; "follow" mode always hashes the followed
  content; "skip" entries are already excluded from the tree map.

Phase 2 — fetch content only when needed:
- Add getBlob(sha, path) to GitServiceInterface: GitHub/Gitea use
  `git/blobs/{sha}` (base64 JSON, shared via a new
  fetchGitHubStyleBlob() base helper); GitLab uses
  `repository/blobs/{sha}/raw` (raw bytes, not JSON-wrapped).
- The diff panel no longer requires content prefetched during refresh.
  FileListItem's Diff button always renders for a modified file and
  fetches remote content on demand via a new onExpandDiff callback,
  showing a loading placeholder while the request is in flight and
  caching the result on the FileStatus so re-expanding doesn't refetch.
- A modified symlink shows "Symlink target changed" instead of running
  a text diff.

Known limitation (pre-existing, not introduced here): comparing raw
file bytes has no CRLF/line-ending normalization, so a repo checked
out elsewhere with core.autocrlf could show a false "modified" status.
The previous content-based comparison (contentsEqual) had the same
exact-byte-equality behavior, so this isn't a regression.

Closes #36
This commit is contained in:
ClaudiaFang 2026-07-13 13:30:10 +00:00
parent 29bf3604e3
commit 2ed5a436b0
16 changed files with 366 additions and 92 deletions

View file

@ -23,6 +23,7 @@ export interface GitHubTreeItem {
path: string; path: string;
type: string; type: string;
mode?: string; mode?: string;
sha?: string;
} }
export interface GitHubTreeResponse { export interface GitHubTreeResponse {
@ -41,6 +42,8 @@ export interface GitLabTreeItem {
path: string; path: string;
type: string; type: string;
mode?: string; mode?: string;
/** GitLab's tree API calls the blob SHA "id", not "sha". */
id?: string;
} }
export interface ConnectionTestResult { export interface ConnectionTestResult {
@ -199,6 +202,16 @@ export abstract class BaseGitService {
return this.isBinary(path) ? bytes.buffer : new TextDecoder().decode(bytes); 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<GitFile> {
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 { protected handleFileNotFound(e: unknown): GitFile {
if (e instanceof Error && e.message.includes('404')) { if (e instanceof Error && e.message.includes('404')) {
return { content: '', sha: '' }; return { content: '', sha: '' };

View file

@ -13,6 +13,13 @@ export interface GitFile {
export interface GitTreeEntry { export interface GitTreeEntry {
path: string; path: string;
symlink: boolean; 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 { export interface GitServiceInterface {
@ -32,4 +39,11 @@ export interface GitServiceInterface {
pushSymlink?(path: string, target: string, branch: string, commitMessage: string): Promise<{ path: string, sha?: string }>; pushSymlink?(path: string, target: string, branch: string, commitMessage: string): Promise<{ path: string, sha?: string }>;
deleteFile(path: string, branch: string, commitMessage: string): Promise<void>; deleteFile(path: string, branch: string, commitMessage: string): Promise<void>;
getRepoGitignores(branch: string): Promise<string[]>; getRepoGitignores(branch: string): Promise<string[]>;
/**
* 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<GitFile>;
} }

View file

@ -77,7 +77,7 @@ export class GiteaService extends BaseGitService implements GitServiceInterface
const entries = treeData.tree const entries = treeData.tree
.filter(item => item.type === 'blob') .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; if (!useFilter) return entries;
@ -88,6 +88,10 @@ export class GiteaService extends BaseGitService implements GitServiceInterface
}); });
} }
async getBlob(sha: string, path: string): Promise<GitFile> {
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<void> { async deleteFile(path: string, branch: string, message: string): Promise<void> {
const file = await this.getFile(path, branch); const file = await this.getFile(path, branch);
const url = this.getApiUrl(path); const url = this.getApiUrl(path);

View file

@ -111,7 +111,7 @@ export class GitHubService extends BaseGitService implements GitServiceInterface
const entries = data.tree const entries = data.tree
.filter(item => item.type === 'blob') .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; if (!useFilter) return entries;
@ -122,6 +122,10 @@ export class GitHubService extends BaseGitService implements GitServiceInterface
}); });
} }
async getBlob(sha: string, path: string): Promise<GitFile> {
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<void> { async deleteFile(path: string, branch: string, message: string): Promise<void> {
const file = await this.getFile(path, branch); const file = await this.getFile(path, branch);
const url = this.getApiUrl(path); const url = this.getApiUrl(path);

View file

@ -75,7 +75,7 @@ export class GitLabService extends BaseGitService implements GitServiceInterface
const entries = data const entries = data
.filter(item => item.type === 'blob') .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) { if (useFilter) {
const filtered = entries.filter(e => { const filtered = entries.filter(e => {
@ -95,6 +95,16 @@ export class GitLabService extends BaseGitService implements GitServiceInterface
return allEntries; return allEntries;
} }
async getBlob(sha: string, path: string): Promise<GitFile> {
// 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<void> { async deleteFile(path: string, branch: string, message: string): Promise<void> {
const url = this.getApiUrl(path); const url = this.getApiUrl(path);
const body = { const body = {

View file

@ -1,6 +1,6 @@
import { ItemView, WorkspaceLeaf, TFile, Notice, Platform, setIcon, setTooltip } from 'obsidian'; import { ItemView, WorkspaceLeaf, TFile, Notice, Platform, setIcon, setTooltip } from 'obsidian';
import GitLabFilesPush from '../main'; import GitLabFilesPush from '../main';
import { getServiceName, getEffectiveSymlinkHandling } from '../settings'; import { getServiceName, getEffectiveSymlinkHandling, type SymlinkHandling } from '../settings';
import { ConfirmModal } from './ConfirmModal'; import { ConfirmModal } from './ConfirmModal';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import { type FileStatus, type FilterValue } from './types'; import { type FileStatus, type FilterValue } from './types';
@ -9,6 +9,8 @@ import { renderFileItem, statusMeta, type FileItemCallbacks } from './components
import { ICONS } from './components/icons'; import { ICONS } from './components/icons';
import { isBinaryPath, contentsEqual } from '../utils/path'; import { isBinaryPath, contentsEqual } from '../utils/path';
import { readLocalSymlinkTarget } from '../utils/symlink'; 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'; 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'), onPush: (fs) => void this.runSingleFile(fs, 'push'),
onPull: (fs) => void this.runSingleFile(fs, 'pull'), onPull: (fs) => void this.runSingleFile(fs, 'pull'),
onDelete: (fs) => void this.handleLocalDelete(fs), 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<void> {
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 { private renderFileList(container: HTMLElement): void {
const all = Array.from(this.fileStatuses.values()); const all = Array.from(this.fileStatuses.values());
const statuses = this.statusFilter === 'all' const statuses = this.statusFilter === 'all'
@ -255,11 +274,11 @@ export class SyncStatusView extends ItemView {
} }
await new Promise(r => window.setTimeout(r, 500)); 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(); this.renderView();
} catch (e) { } catch (e) {
new Notice(`${op === 'push' ? 'Push' : 'Pull'} failed: ${e instanceof Error ? e.message : String(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(); this.renderView();
} }
} }
@ -289,7 +308,7 @@ export class SyncStatusView extends ItemView {
this.renderView(); this.renderView();
const filesToCheck = this.getCheckableFiles(files.local, extra, files.hiddenLocalPaths); const filesToCheck = this.getCheckableFiles(files.local, extra, files.hiddenLocalPaths);
await this.performStatusCheck(filesToCheck); await this.performStatusCheck(filesToCheck, files.remoteMap);
this.lastSyncTime = Date.now(); this.lastSyncTime = Date.now();
this.isRefreshing = false; // Set to false BEFORE final renderView this.isRefreshing = false; // Set to false BEFORE final renderView
@ -310,7 +329,7 @@ export class SyncStatusView extends ItemView {
await this.plugin.gitignoreManager.loadGitignores(); await this.plugin.gitignoreManager.loadGitignores();
// Map remote paths to vault paths // Map remote paths to vault paths
const remoteMap = new Map<string, string>(); // vaultPath -> remoteFullPath const remoteMap = new Map<string, GitTreeEntry>(); // vaultPath -> tree entry (path, symlink, sha)
const skipSymlinks = getEffectiveSymlinkHandling(this.plugin.settings) === 'skip'; const skipSymlinks = getEffectiveSymlinkHandling(this.plugin.settings) === 'skip';
for (const entry of remoteEntries) { for (const entry of remoteEntries) {
if (entry.symlink && skipSymlinks) continue; // Symlink handling: skip if (entry.symlink && skipSymlinks) continue; // Symlink handling: skip
@ -319,7 +338,7 @@ export class SyncStatusView extends ItemView {
const vaultPath = this.plugin.getVaultPath(normalized); const vaultPath = this.plugin.getVaultPath(normalized);
if (!this.plugin.gitignoreManager.isIgnored(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<string, string>, localFilePaths: Set<string>, allLocalFileMap: Map<string, TFile>) { private async identifyExtraFiles(remoteMap: Map<string, GitTreeEntry>, localFilePaths: Set<string>, allLocalFileMap: Map<string, TFile>) {
const extra: Array<TFile | string> = []; const extra: Array<TFile | string> = [];
for (const [vaultPath] of remoteMap.entries()) { for (const [vaultPath] of remoteMap.entries()) {
if (localFilePaths.has(vaultPath)) continue; if (localFilePaths.has(vaultPath)) continue;
@ -440,7 +459,7 @@ export class SyncStatusView extends ItemView {
private static readonly STATUS_CHECK_CONCURRENCY = 8; private static readonly STATUS_CHECK_CONCURRENCY = 8;
private static readonly RENDER_THROTTLE_MS = 150; private static readonly RENDER_THROTTLE_MS = 150;
private async performStatusCheck(filesToCheck: Array<TFile | string>): Promise<void> { private async performStatusCheck(filesToCheck: Array<TFile | string>, remoteMap: Map<string, GitTreeEntry>): Promise<void> {
const total = filesToCheck.length; const total = filesToCheck.length;
this.refreshProgress = { current: 0, total }; this.refreshProgress = { current: 0, total };
@ -457,7 +476,10 @@ export class SyncStatusView extends ItemView {
const worker = async (): Promise<void> => { const worker = async (): Promise<void> => {
while (next < total) { while (next < total) {
const file = filesToCheck[next++]; 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++; this.refreshProgress.current++;
maybeRender(); maybeRender();
} }
@ -468,22 +490,20 @@ export class SyncStatusView extends ItemView {
maybeRender(true); maybeRender(true);
} }
private async refreshFileStatus(fileOrPath: TFile | string): Promise<void> { /**
* 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<void> {
try { try {
const isStr = typeof fileOrPath === 'string'; if (remoteEntry?.sha !== undefined) {
const path = isStr ? fileOrPath : fileOrPath.path; await this.refreshFileStatusBySha(fileOrPath, remoteEntry);
const file = isStr ? undefined : fileOrPath; } else {
await this.refreshFileStatusByContent(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 });
} catch (e) { } catch (e) {
const path = typeof fileOrPath === 'string' ? fileOrPath : fileOrPath.path; const path = typeof fileOrPath === 'string' ? fileOrPath : fileOrPath.path;
logger.warn(`Failed to determine sync status for ${path}`, e); 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<void> {
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<string | ArrayBuffer> {
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<void> {
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<string | ArrayBuffer> { private async readFileContent(fileOrPath: TFile | string, binary: boolean, isStr: boolean): Promise<string | ArrayBuffer> {
if (isStr) { if (isStr) {
return binary return binary
@ -519,22 +593,10 @@ export class SyncStatusView extends ItemView {
throw new Error('Expected TFile when isStr is false'); 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 } { private determineFileStatus(localContent: string | ArrayBuffer, remote: { sha?: string; content?: string | ArrayBuffer }): FileStatus['status'] {
if (!remote.sha) { if (!remote.sha) return 'unsynced';
return { status: 'unsynced' }; if (remote.content && this.contentsEqual(localContent, remote.content)) return 'synced';
} return 'modified';
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 isBinary(path: string): boolean { return isBinaryPath(path); } private isBinary(path: string): boolean { return isBinaryPath(path); }
@ -543,21 +605,6 @@ export class SyncStatusView extends ItemView {
return contentsEqual(a, b); 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 ───────────────────────────────────── // ── Batch push/pull/delete ─────────────────────────────────────
async pushAllModified(): Promise<void> { await this.runBatchOperation('modified', 'push'); } async pushAllModified(): Promise<void> { await this.runBatchOperation('modified', 'push'); }

View file

@ -1,10 +1,10 @@
import { computeSideBySideDiff, type DiffSide } from '../../utils/diff'; import { computeSideBySideDiff, type DiffSide } from '../../utils/diff';
export function renderDiffPanel(fileEl: HTMLElement, remoteContent: string, localContent: string): HTMLElement { /** Renders the side-by-side + unified diff body into an existing container. */
const diffEl = fileEl.createDiv({ cls: 'ssv-diff' }); export function renderDiffPanel(container: HTMLElement, remoteContent: string, localContent: string): void {
const rows = computeSideBySideDiff(remoteContent, localContent); 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: 'Remote' });
grid.createDiv({ cls: 'ssv-diff-hd', text: 'Local' }); grid.createDiv({ cls: 'ssv-diff-hd', text: 'Local' });
for (const row of rows) { for (const row of rows) {
@ -12,14 +12,12 @@ export function renderDiffPanel(fileEl: HTMLElement, remoteContent: string, loca
renderDiffCell(grid, row.right); 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) { for (const { left, right } of rows) {
if (left.type === 'removed') unifiedEl.createSpan({ cls: 'ssv-u-line removed' }).textContent = `- ${left.content ?? ''}\n`; 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 (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`; if (left.type === 'unchanged') unifiedEl.createSpan({ cls: 'ssv-u-line unchanged' }).textContent = ` ${left.content ?? ''}\n`;
} }
return diffEl;
} }
function renderDiffCell(grid: HTMLElement, side: DiffSide): void { function renderDiffCell(grid: HTMLElement, side: DiffSide): void {

View file

@ -8,6 +8,13 @@ export interface FileItemCallbacks {
onPush: (fileStatus: FileStatus) => void; onPush: (fileStatus: FileStatus) => void;
onPull: (fileStatus: FileStatus) => void; onPull: (fileStatus: FileStatus) => void;
onDelete: (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<void>;
} }
// `icon` is a Lucide icon id (rendered via Obsidian's setIcon) so every status // `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 { function renderFileActions(fileEl: HTMLElement, fileStatus: FileStatus, callbacks: FileItemCallbacks): void {
const actions = fileEl.createDiv({ cls: 'ssv-file-actions' }); const actions = fileEl.createDiv({ cls: 'ssv-file-actions' });
if (fileStatus.status === 'modified' && fileStatus.diff) { if (fileStatus.status === 'modified') {
renderDiffToggleButton(actions, fileEl, fileStatus); renderDiffToggleButton(actions, fileEl, fileStatus, callbacks);
} }
if (fileStatus.status === 'modified' || fileStatus.status === 'unsynced') { 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 diffBtn = actions.createEl('button', { cls: 'ssv-action-btn diff' });
const iconEl = diffBtn.createSpan(); const iconEl = diffBtn.createSpan();
setIcon(iconEl, ICONS.diff); setIcon(iconEl, ICONS.diff);
const btnLabel = diffBtn.createSpan({ cls: 'ssv-btn-label', text: ' Diff' }); const btnLabel = diffBtn.createSpan({ cls: 'ssv-btn-label', text: ' Diff' });
let diffEl: HTMLElement; const diffEl = fileEl.createDiv({ cls: 'ssv-diff' });
if (typeof fileStatus.remoteContent === 'string' && typeof fileStatus.localContent === 'string') { renderDiffBody(diffEl, fileStatus);
diffEl = renderDiffPanel(fileEl, fileStatus.remoteContent, fileStatus.localContent);
} else {
diffEl = fileEl.createDiv({ cls: 'ssv-diff' });
diffEl.createDiv({ cls: 'ssv-diff-binary', text: 'Binary file changed' });
}
setTooltip(diffBtn, 'Toggle diff view'); setTooltip(diffBtn, 'Toggle diff view');
diffBtn.addEventListener('click', () => { diffBtn.addEventListener('click', () => {
const open = diffEl.hasClass('visible'); 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); diffEl.toggleClass('visible', !open);
btnLabel.setText(open ? ' Diff' : ' Hide'); btnLabel.setText(open ? ' Diff' : ' Hide');
setIcon(iconEl, open ? ICONS.diff : ICONS.diffOpen); 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 { 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}` }); const btn = actions.createEl('button', { cls: `ssv-action-btn ${cls}` });
setIcon(btn.createSpan(), icon); setIcon(btn.createSpan(), icon);

View file

@ -7,7 +7,8 @@ export interface FileStatus {
localContent?: string | ArrayBuffer; localContent?: string | ArrayBuffer;
remoteContent?: string | ArrayBuffer; remoteContent?: string | ArrayBuffer;
remoteSha?: string; 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'; export type FilterValue = 'all' | 'synced' | 'modified' | 'unsynced' | 'remote-only';

21
src/utils/git-blob-sha.ts Normal file
View file

@ -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<string> {
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('');
}

View file

@ -184,6 +184,33 @@ describe('GiteaService', () => {
mockRequest({ status: 404, json: { message: 'branch does not exist' }, text: 'branch does not exist' }); 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/); 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', () => { describe('deleteFile', () => {

View file

@ -196,6 +196,33 @@ describe('GitHubService', () => {
mockRequest({ status: 404, json: { message: 'Not Found' }, text: 'Not Found' }); mockRequest({ status: 404, json: { message: 'Not Found' }, text: 'Not Found' });
await expect(service.listFiles('missing-branch')).rejects.toThrow(/Branch "missing-branch" was 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', () => { describe('deleteFile', () => {

View file

@ -148,6 +148,38 @@ describe('GitLabService', () => {
mockRequest({ status: 404, json: { message: '404 Branch Not Found' }, text: '404 Branch Not Found' }); 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/); 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', () => { describe('deleteFile', () => {

View file

@ -11,9 +11,9 @@ describe('renderDiffPanel', () => {
container = createContainer(); container = createContainer();
}); });
it('returns the diff element with ssv-diff class', () => { it('renders the diff grid into the given container', () => {
const el = renderDiffPanel(container, '', ''); renderDiffPanel(container, '', '');
expect(el.classList.contains('ssv-diff')).toBe(true); expect(container.querySelector('.ssv-diff-grid')).not.toBeNull();
}); });
it('renders Remote and Local column headers', () => { it('renders Remote and Local column headers', () => {

View file

@ -44,6 +44,7 @@ describe('renderFileItem', () => {
onPush: vi.fn(), onPush: vi.fn(),
onPull: vi.fn(), onPull: vi.fn(),
onDelete: vi.fn(), onDelete: vi.fn(),
onExpandDiff: vi.fn().mockResolvedValue(undefined),
}; };
}); });
@ -119,33 +120,27 @@ describe('renderFileItem', () => {
expect(callbacks.onPull).toHaveBeenCalledWith(fs); expect(callbacks.onPull).toHaveBeenCalledWith(fs);
}); });
it('renders diff toggle button when diff content is present', () => { it('renders diff toggle button for any modified file, even without preloaded content', () => {
const fs = makeFileStatus('modified', { diff: 'some diff', localContent: 'b', remoteContent: 'a' }); const fs = makeFileStatus('modified', { file: mockFile });
renderFileItem(container, fs, false, callbacks); renderFileItem(container, fs, false, callbacks);
expect(container.querySelector('.ssv-action-btn.diff')).not.toBeNull(); 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', () => { 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); renderFileItem(container, fs, false, callbacks);
expect(container.querySelector('.ssv-diff')?.classList.contains('visible')).toBe(false); expect(container.querySelector('.ssv-diff')?.classList.contains('visible')).toBe(false);
}); });
it('diff panel becomes visible on first click', () => { 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); renderFileItem(container, fs, false, callbacks);
(container.querySelector('.ssv-action-btn.diff') as HTMLButtonElement).click(); (container.querySelector('.ssv-action-btn.diff') as HTMLButtonElement).click();
expect(container.querySelector('.ssv-diff')?.classList.contains('visible')).toBe(true); expect(container.querySelector('.ssv-diff')?.classList.contains('visible')).toBe(true);
}); });
it('diff panel hides on second click', () => { 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); renderFileItem(container, fs, false, callbacks);
const btn = container.querySelector('.ssv-action-btn.diff') as HTMLButtonElement; const btn = container.querySelector('.ssv-action-btn.diff') as HTMLButtonElement;
btn.click(); btn.click();
@ -154,7 +149,7 @@ describe('renderFileItem', () => {
}); });
it('diff button label toggles between " Diff" and " Hide"', () => { 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); renderFileItem(container, fs, false, callbacks);
const btn = container.querySelector('.ssv-action-btn.diff') as HTMLButtonElement; const btn = container.querySelector('.ssv-action-btn.diff') as HTMLButtonElement;
const label = btn.querySelector('.ssv-btn-label') as HTMLElement; const label = btn.querySelector('.ssv-btn-label') as HTMLElement;
@ -164,6 +159,29 @@ describe('renderFileItem', () => {
btn.click(); btn.click();
expect(label.textContent).toBe(' Diff'); 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', () => { describe('unsynced file', () => {

View file

@ -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'));
});
});