diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dff67a5..da48184 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,6 @@ jobs: uses: firstsun-dev/.github/.github/workflows/obsidian-plugin-ci.yml@v1 with: plugin-id: "git-file-sync" - skip-sonar: true secrets: RELEASE_TOKEN: ${{ secrets.RELEASE_TOKEN }} diff --git a/CHANGELOG.md b/CHANGELOG.md index efaad64..8b9ee5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## [1.1.2](https://github.com/firstsun-dev/git-files-sync/compare/1.1.1...1.1.2) (2026-06-16) + +### Bug Fixes + +* **lint:** replace activeWindow.setTimeout with setTimeout ([4099f44](https://github.com/firstsun-dev/git-files-sync/commit/4099f44fa5643ce5799eef74fb23f41fae0da991)) + +## [1.1.1](https://github.com/firstsun-dev/git-files-sync/compare/1.1.0...1.1.1) (2026-06-16) + +### Bug Fixes + +* **ci:** switch to shared workflow v1 and fix repository url ([3a0f99e](https://github.com/firstsun-dev/git-files-sync/commit/3a0f99e3832e5e288ad586ed856252f829a828ec)) + ## [1.1.0](https://github.com/firstsun-dev/git-files-sync/compare/1.0.6...1.1.0) (2026-06-16) ### Features diff --git a/README.md b/README.md index 85db3e1..7bfb843 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ [繁體中文使用說明](USAGE_zh.md) - +[git-file-sync](https://github.com/user-attachments/assets/78f383be-e8a4-4c9c-8845-1f46d06ea661) ![sync-status](imgs/sync-status.png) *The Sync Status View provides a clear overview of your files, allowing you to selectively push, pull, or view diffs for modified files.* diff --git a/imgs/git-file-sync-en.webm b/imgs/git-file-sync-en.webm index 75fa63e..e70ef4d 100644 Binary files a/imgs/git-file-sync-en.webm and b/imgs/git-file-sync-en.webm differ diff --git a/imgs/git-file-sync-zh.webm b/imgs/git-file-sync-zh.webm index 1649a38..a80b75e 100644 Binary files a/imgs/git-file-sync-zh.webm and b/imgs/git-file-sync-zh.webm differ diff --git a/manifest.json b/manifest.json index e84ebd7..6de16eb 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "git-file-sync", "name": "Git File Sync", - "version": "1.1.0", + "version": "1.1.2", "minAppVersion": "1.12.7", "description": "Selectively sync individual notes with GitLab or GitHub. Push, pull, diff, and resolve conflicts — file by file, on mobile and desktop.", "author": "ClaudiaFang", diff --git a/package-lock.json b/package-lock.json index 739f060..f69825f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "git-file-sync", - "version": "1.1.0", + "version": "1.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "git-file-sync", - "version": "1.1.0", + "version": "1.1.2", "license": "MIT", "dependencies": { "ignore": "^7.0.5", diff --git a/package.json b/package.json index fd2dd93..16e65e2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "git-file-sync", - "version": "1.1.0", + "version": "1.1.2", "description": "Selectively sync individual notes with GitLab or GitHub. Push, pull, diff, and resolve conflicts — file by file, on mobile and desktop.", "main": "main.js", "type": "module", diff --git a/src/logic/sync-manager.ts b/src/logic/sync-manager.ts index d3af1c0..60061e7 100644 --- a/src/logic/sync-manager.ts +++ b/src/logic/sync-manager.ts @@ -352,13 +352,22 @@ export class SyncManager { const binary = this.isBinary(path); if (typeof fileOrPath === 'string') { - return binary + return binary ? await this.app.vault.adapter.readBinary(fileOrPath) : await this.app.vault.adapter.read(fileOrPath); } - return binary - ? await this.app.vault.readBinary(fileOrPath) - : await this.app.vault.read(fileOrPath); + try { + return binary + ? await this.app.vault.readBinary(fileOrPath) + : await this.app.vault.read(fileOrPath); + } catch (e) { + // Obsidian's cached vault.read can fail for symlinked files (notably + // on mobile); fall back to reading the path directly via the adapter. + logger.warn(`vault.read failed for ${path}; falling back to adapter`, e); + return binary + ? await this.app.vault.adapter.readBinary(path) + : await this.app.vault.adapter.read(path); + } } private async processSingleBatchPush(fileOrPath: TFile | string, path: string, name: string, isString: boolean): Promise { diff --git a/src/main.ts b/src/main.ts index 2b41db1..7c1ad7b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -25,7 +25,7 @@ export default class GitLabFilesPush extends Plugin { (leaf) => new SyncStatusView(leaf, this) ); - this.addRibbonIcon('list-checks', 'Open sync status', async () => { + this.addRibbonIcon('git-compare', 'Open sync status', async () => { await this.activateSyncStatusView(); }); diff --git a/src/services/git-service-base.ts b/src/services/git-service-base.ts index 3cd997e..62722ce 100644 --- a/src/services/git-service-base.ts +++ b/src/services/git-service-base.ts @@ -1,11 +1,16 @@ import { requestUrl, RequestUrlResponse } from 'obsidian'; import { logger } from '../utils/logger'; +import { GitTreeEntry } from './git-service-interface'; export interface GitFile { content: string | ArrayBuffer; sha: string; } +// Git file mode for a symbolic link. Symlinks are stored as blobs whose content +// is the link target path, so they need special handling during sync. +export const GIT_SYMLINK_MODE = '120000'; + export interface GitHubContentResponse { content: string; sha: string; @@ -15,6 +20,7 @@ export interface GitHubContentResponse { export interface GitHubTreeItem { path: string; type: string; + mode?: string; } export interface GitHubTreeResponse { @@ -32,6 +38,7 @@ export interface GitLabFileResponse { export interface GitLabTreeItem { path: string; type: string; + mode?: string; } export abstract class BaseGitService { @@ -42,6 +49,7 @@ export abstract class BaseGitService { * Safely wraps requestUrl to handle potential throws from Obsidian and provide better error messages. */ protected async safeRequest(url: string, method: string, body?: unknown, extraHeaders?: Record, silent = false): Promise { + let response: RequestUrlResponse; try { const headers: Record = { ...extraHeaders, @@ -57,30 +65,73 @@ export abstract class BaseGitService { throw: false }; - const response = await requestUrl(options); - - if (response.status >= 400) { - const errorMsg = this.parseErrorResponse(response); - if (!silent) logger.error(`Git Service Request Failed (${response.status}): ${url}`, errorMsg); - throw new Error(`Git Service Error (${response.status}): ${errorMsg}`); - } - - return response; + response = await requestUrl(options); } catch (error) { + // Network-level failure (DNS, offline, TLS, etc.) if (!silent) logger.error('Git Service Request Failed:', error); if (error instanceof Error) throw error; throw new Error(`Network error or unexpected failure: ${String(error)}`); } + + if (response.status >= 400) { + const errorMsg = this.parseErrorResponse(response); + // 404 is routinely an expected "does not exist" probe (getFile treats + // it as an empty file, gitignore lookups ignore it). Log it at debug + // level so it doesn't surface as a failure, but still throw so callers + // can handle it. Other statuses are genuine errors. + if (!silent) { + if (response.status === 404) logger.debug(`Git Service 404 (not found): ${url}`); + else logger.error(`Git Service Request Failed (${response.status}): ${url}`, errorMsg); + } + throw new Error(`Git Service Error (${response.status}): ${errorMsg}`); + } + + return response; } protected abstract addAuthHeader(headers: Record): void; + /** + * Safely parses a response body as JSON. + * + * Some servers/proxies return a 2xx (or redirect) response whose body is an + * HTML page — a login screen, an SSO redirect, or a proxy/error page — rather + * than JSON. Accessing `response.json` directly then throws a cryptic + * "Unexpected token '<', \"(response: RequestUrlResponse): T { + const contentType = (response.headers?.['content-type'] ?? response.headers?.['Content-Type'] ?? '').toLowerCase(); + const bodyText = (response.text ?? '').trimStart(); + const looksLikeHtml = bodyText.startsWith('<') || contentType.includes('html'); + + try { + const data = response.json as T; + if (data === undefined && looksLikeHtml) throw new Error('non-JSON response'); + return data; + } catch (e) { + if (looksLikeHtml) { + throw new Error( + 'Expected a JSON response from the Git server but received an HTML page ' + + '(likely a login, SSO redirect, or proxy/error page). ' + + 'Please check the server URL, your access token, and any network proxy or firewall.' + ); + } + throw new Error(`Failed to parse the Git server response as JSON: ${e instanceof Error ? e.message : String(e)}`); + } + } + protected parseErrorResponse(response: RequestUrlResponse): string { try { const data = response.json as { message?: string; error?: string }; return data.message || data.error || JSON.stringify(data); } catch { - return response.text || 'Unknown error'; + const bodyText = (response.text ?? '').trim(); + const contentType = (response.headers?.['content-type'] ?? response.headers?.['Content-Type'] ?? '').toLowerCase(); + if (bodyText.startsWith('<') || contentType.includes('html')) { + return 'Received an HTML page instead of a JSON error (likely a login, redirect, or proxy/error page). Check the server URL, access token, and network proxy.'; + } + return bodyText || 'Unknown error'; } } @@ -153,9 +204,14 @@ export abstract class BaseGitService { } } + async listFiles(branch: string, useFilter = true): Promise { + const entries = await this.listFilesDetailed(branch, useFilter); + return entries.map(e => e.path); + } + abstract getFile(path: string, branch: string): Promise; abstract pushFile(path: string, content: string | ArrayBuffer, branch: string, message: string, sha?: string): Promise<{ path: string, sha?: string }>; - abstract listFiles(branch: string, useFilter?: boolean): Promise; + abstract listFilesDetailed(branch: string, useFilter?: boolean): Promise; abstract deleteFile(path: string, branch: string, message: string): Promise; abstract testConnection(): Promise; } diff --git a/src/services/git-service-interface.ts b/src/services/git-service-interface.ts index 3357260..e4ca517 100644 --- a/src/services/git-service-interface.ts +++ b/src/services/git-service-interface.ts @@ -3,12 +3,20 @@ export interface GitFile { sha: string; } +/** A file entry from the repository tree, with whether it is a symbolic link. */ +export interface GitTreeEntry { + path: string; + symlink: boolean; +} + export interface GitServiceInterface { updateConfig(...args: unknown[]): void; getFile(path: string, branch: string): Promise; pushFile(path: string, content: string | ArrayBuffer, branch: string, commitMessage: string, existingSha?: string): Promise<{ path: string, sha?: string }>; testConnection(): Promise; listFiles(branch: string, useFilter?: boolean): Promise; + /** Like listFiles but also reports which entries are symbolic links (mode 120000). */ + listFilesDetailed(branch: string, useFilter?: boolean): Promise; deleteFile(path: string, branch: string, commitMessage: string): Promise; getRepoGitignores(branch: string): Promise; } diff --git a/src/services/gitea-service.ts b/src/services/gitea-service.ts index 1afe9aa..c5ffc8e 100644 --- a/src/services/gitea-service.ts +++ b/src/services/gitea-service.ts @@ -1,5 +1,5 @@ -import { GitServiceInterface } from './git-service-interface'; -import { BaseGitService, GitFile, GitHubContentResponse, GitHubTreeResponse } from './git-service-base'; +import { GitServiceInterface, GitTreeEntry } from './git-service-interface'; +import { BaseGitService, GitFile, GitHubContentResponse, GitHubTreeResponse, GIT_SYMLINK_MODE } from './git-service-base'; import { logger } from '../utils/logger'; export class GiteaService extends BaseGitService implements GitServiceInterface { @@ -28,7 +28,7 @@ export class GiteaService extends BaseGitService implements GitServiceInterface try { const url = `${this.getApiUrl(path)}?ref=${branch}`; const response = await this.safeRequest(url, 'GET'); - const data = response.json as GitHubContentResponse; + const data = this.parseJson(response); return { content: this.decodeContent(data.content, path), @@ -41,45 +41,46 @@ export class GiteaService extends BaseGitService implements GitServiceInterface async pushFile(path: string, content: string | ArrayBuffer, branch: string, message: string, sha?: string): Promise<{ path: string, sha?: string }> { const url = this.getApiUrl(path); - const body = { + const body: { message: string; content: string; branch: string; sha?: string } = { message, content: this.encodeContent(content), branch, - sha }; + // Only send sha when updating an existing file; a blank sha is rejected. + if (sha) body.sha = sha; const method = sha ? 'PUT' : 'POST'; const response = await this.safeRequest(url, method, body); - const data = response.json as { content: { path: string, sha: string } }; + const data = this.parseJson<{ content: { path: string, sha: string } }>(response); return { path: data.content.path, sha: data.content.sha }; } - async listFiles(branch: string, useFilter = true): Promise { + async listFilesDetailed(branch: string, useFilter = true): Promise { // Resolve branch name to commit SHA first for compatibility with all Gitea versions, // since the git/trees endpoint requires a SHA (not a ref name) on older instances. const branchUrl = `${this.baseUrl}/api/v1/repos/${this.owner}/${this.repo}/branches/${branch}`; const branchResponse = await this.safeRequest(branchUrl, 'GET'); - const branchData = branchResponse.json as { commit: { id: string } }; + const branchData = this.parseJson<{ commit: { id: string } }>(branchResponse); const commitSha = branchData.commit.id; const treeUrl = `${this.baseUrl}/api/v1/repos/${this.owner}/${this.repo}/git/trees/${commitSha}?recursive=1`; const treeResponse = await this.safeRequest(treeUrl, 'GET'); - const treeData = treeResponse.json as GitHubTreeResponse; + const treeData = this.parseJson(treeResponse); if (treeData.truncated) { logger.warn('Gitea tree result is truncated. Some files might not be shown.'); } - const files = treeData.tree + const entries = treeData.tree .filter(item => item.type === 'blob') - .map(item => item.path); + .map(item => ({ path: item.path, symlink: item.mode === GIT_SYMLINK_MODE })); - if (!useFilter) return files; + if (!useFilter) return entries; - return files.filter(p => { + return entries.filter(e => { if (!this.rootPath) return true; const cleanRoot = this.rootPath.endsWith('/') ? this.rootPath : `${this.rootPath}/`; - return p === this.rootPath || p.startsWith(cleanRoot); + return e.path === this.rootPath || e.path.startsWith(cleanRoot); }); } diff --git a/src/services/github-service.ts b/src/services/github-service.ts index 2d661b7..416408a 100644 --- a/src/services/github-service.ts +++ b/src/services/github-service.ts @@ -1,5 +1,5 @@ -import { GitServiceInterface } from './git-service-interface'; -import { BaseGitService, GitFile, GitHubContentResponse, GitHubTreeResponse } from './git-service-base'; +import { GitServiceInterface, GitTreeEntry } from './git-service-interface'; +import { BaseGitService, GitFile, GitHubContentResponse, GitHubTreeResponse, GIT_SYMLINK_MODE } from './git-service-base'; import { logger } from '../utils/logger'; export class GitHubService extends BaseGitService implements GitServiceInterface { @@ -26,7 +26,7 @@ export class GitHubService extends BaseGitService implements GitServiceInterface try { const url = `${this.getApiUrl(path)}?ref=${branch}`; const response = await this.safeRequest(url, 'GET'); - const data = response.json as GitHubContentResponse; + const data = this.parseJson(response); return { content: this.decodeContent(data.content, path), @@ -39,37 +39,40 @@ export class GitHubService extends BaseGitService implements GitServiceInterface async pushFile(path: string, content: string | ArrayBuffer, branch: string, message: string, sha?: string): Promise<{ path: string, sha?: string }> { const url = this.getApiUrl(path); - const body = { + const body: { message: string; content: string; branch: string; sha?: string } = { message, content: this.encodeContent(content), branch, - sha }; + // GitHub's Contents API rejects a blank sha with HTTP 422. Only include + // it when updating an existing file; a 404 lookup yields sha === '' for + // new files, which must be created without a sha. + if (sha) body.sha = sha; const response = await this.safeRequest(url, 'PUT', body); - const data = response.json as { content: { path: string, sha: string } }; + const data = this.parseJson<{ content: { path: string, sha: string } }>(response); return { path: data.content.path, sha: data.content.sha }; } - async listFiles(branch: string, useFilter = true): Promise { + async listFilesDetailed(branch: string, useFilter = true): Promise { const url = `https://api.github.com/repos/${this.owner}/${this.repo}/git/trees/${branch}?recursive=1`; const response = await this.safeRequest(url, 'GET'); - const data = response.json as GitHubTreeResponse; - + const data = this.parseJson(response); + if (data.truncated) { logger.warn('GitHub tree result is truncated. Some files might not be shown.'); } - const files = data.tree + const entries = data.tree .filter(item => item.type === 'blob') - .map(item => item.path); + .map(item => ({ path: item.path, symlink: item.mode === GIT_SYMLINK_MODE })); - if (!useFilter) return files; + if (!useFilter) return entries; - return files.filter(p => { + return entries.filter(e => { if (!this.rootPath) return true; const cleanRoot = this.rootPath.endsWith('/') ? this.rootPath : `${this.rootPath}/`; - return p === this.rootPath || p.startsWith(cleanRoot); + return e.path === this.rootPath || e.path.startsWith(cleanRoot); }); } diff --git a/src/services/gitlab-service.ts b/src/services/gitlab-service.ts index 281e0ef..1fbf001 100644 --- a/src/services/gitlab-service.ts +++ b/src/services/gitlab-service.ts @@ -1,5 +1,5 @@ -import { GitServiceInterface } from './git-service-interface'; -import { BaseGitService, GitFile, GitLabFileResponse, GitLabTreeItem } from './git-service-base'; +import { GitServiceInterface, GitTreeEntry } from './git-service-interface'; +import { BaseGitService, GitFile, GitLabFileResponse, GitLabTreeItem, GIT_SYMLINK_MODE } from './git-service-base'; export class GitLabService extends BaseGitService implements GitServiceInterface { private baseUrl: string = 'https://gitlab.com'; @@ -27,7 +27,7 @@ export class GitLabService extends BaseGitService implements GitServiceInterface try { const url = `${this.getApiUrl(path)}?ref=${branch}`; const response = await this.safeRequest(url, 'GET'); - const data = response.json as GitLabFileResponse; + const data = this.parseJson(response); return { content: this.decodeContent(data.content, path), @@ -40,53 +40,54 @@ export class GitLabService extends BaseGitService implements GitServiceInterface async pushFile(path: string, content: string | ArrayBuffer, branch: string, message: string, sha?: string): Promise<{ path: string, sha?: string }> { const url = this.getApiUrl(path); - const body = { + const body: { branch: string; content: string; encoding: string; commit_message: string; last_commit_id?: string } = { branch, content: this.encodeContent(content), encoding: 'base64', commit_message: message, - last_commit_id: sha }; + // A blank sha means the file is new: create it (POST) without last_commit_id. + if (sha) body.last_commit_id = sha; const method = sha ? 'PUT' : 'POST'; const response = await this.safeRequest(url, method, body); - const data = response.json as GitLabFileResponse; + const data = this.parseJson(response); return { path: data.file_path }; } - async listFiles(branch: string, useFilter = true): Promise { + async listFilesDetailed(branch: string, useFilter = true): Promise { const encodedProjectId = encodeURIComponent(this.projectId); - let allPaths: string[] = []; + let allEntries: GitTreeEntry[] = []; let page = 1; const perPage = 100; - + while (true) { const url = `${this.baseUrl}/api/v4/projects/${encodedProjectId}/repository/tree?ref=${branch}&recursive=true&per_page=${perPage}&page=${page}`; const response = await this.safeRequest(url, 'GET'); - const data = response.json as GitLabTreeItem[]; - + const data = this.parseJson(response); + if (!data || data.length === 0) break; - - const paths = data + + const entries = data .filter(item => item.type === 'blob') - .map(item => item.path); - + .map(item => ({ path: item.path, symlink: item.mode === GIT_SYMLINK_MODE })); + if (useFilter) { - const filtered = paths.filter(p => { + const filtered = entries.filter(e => { if (!this.rootPath) return true; const cleanRoot = this.rootPath.endsWith('/') ? this.rootPath : `${this.rootPath}/`; - return p === this.rootPath || p.startsWith(cleanRoot); + return e.path === this.rootPath || e.path.startsWith(cleanRoot); }); - allPaths = allPaths.concat(filtered); + allEntries = allEntries.concat(filtered); } else { - allPaths = allPaths.concat(paths); + allEntries = allEntries.concat(entries); } - + if (data.length < perPage) break; page++; } - - return allPaths; + + return allEntries; } async deleteFile(path: string, branch: string, message: string): Promise { diff --git a/src/settings.ts b/src/settings.ts index c0a8b0e..0fdcae1 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -9,6 +9,15 @@ export interface SyncMetadata { export type GitServiceType = 'gitlab' | 'github' | 'gitea'; +/** + * How symbolic links (Git blobs with mode 120000) are synced: + * - 'real': recreate a real OS symlink on desktop; on mobile (no symlink API) + * fall back to syncing the link target's content as a normal file. + * - 'follow': always sync the target file's content as a normal file. + * - 'skip': ignore symlinks entirely. + */ +export type SymlinkHandling = 'real' | 'follow' | 'skip'; + export interface GitLabFilesPushSettings { serviceType: GitServiceType; gitlabToken: string; @@ -25,6 +34,7 @@ export interface GitLabFilesPushSettings { syncMetadata: Record; rootPath: string; vaultFolder: string; + symlinkHandling: SymlinkHandling; } export function getServiceName(settings: GitLabFilesPushSettings): string { @@ -48,7 +58,8 @@ export const DEFAULT_SETTINGS: GitLabFilesPushSettings = { rootPath: "", branch: 'main', syncMetadata: {}, - vaultFolder: '' + vaultFolder: '', + symlinkHandling: 'real' } export class GitLabSyncSettingTab extends PluginSettingTab { @@ -123,6 +134,19 @@ export class GitLabSyncSettingTab extends PluginSettingTab { void this.plugin.saveSettings(); })); + new Setting(containerEl) + .setName('Symbolic links') + .setDesc('How to sync symlinks: "real" recreates the link on desktop and falls back to the target content on mobile, "follow" always syncs the target content, and "skip" ignores symlinks.') + .addDropdown(dropdown => dropdown + .addOption('real', 'Real symlink (recommended)') + .addOption('follow', 'Follow (sync target content)') + .addOption('skip', 'Skip') + .setValue(this.plugin.settings.symlinkHandling) + .onChange((value: string) => { + this.plugin.settings.symlinkHandling = value as SymlinkHandling; + void this.plugin.saveSettings(); + })); + new Setting(containerEl) .setName('Test connection') .setDesc(`Verify your ${getServiceName(this.plugin.settings)} settings`) diff --git a/src/ui/SyncStatusView.ts b/src/ui/SyncStatusView.ts index 110354b..3311423 100644 --- a/src/ui/SyncStatusView.ts +++ b/src/ui/SyncStatusView.ts @@ -1,11 +1,12 @@ -import { ItemView, WorkspaceLeaf, TFile, Notice, Platform, setTooltip } from 'obsidian'; +import { ItemView, WorkspaceLeaf, TFile, Notice, Platform, setIcon, setTooltip } from 'obsidian'; import GitLabFilesPush from '../main'; import { getServiceName } from '../settings'; import { ConfirmModal } from './ConfirmModal'; import { logger } from '../utils/logger'; import { type FileStatus, type FilterValue } from './types'; import { renderActionBar } from './components/ActionBar'; -import { renderFileItem, type FileItemCallbacks } from './components/FileListItem'; +import { renderFileItem, statusMeta, type FileItemCallbacks } from './components/FileListItem'; +import { ICONS } from './components/icons'; import { isBinaryPath, contentsEqual } from '../utils/path'; export const SYNC_STATUS_VIEW_TYPE = 'sync-status-view'; @@ -92,12 +93,16 @@ export class SyncStatusView extends ItemView { if (!Platform.isMobile) { el.createSpan({ cls: 'ssv-info-sep', text: '·' }); - el.createSpan({ cls: 'ssv-info-item' }).textContent = `⎇ ${this.plugin.settings.branch}`; + const branchItem = el.createSpan({ cls: 'ssv-info-item' }); + setIcon(branchItem.createSpan({ cls: 'ssv-info-icon' }), ICONS.branch); + branchItem.createSpan({ text: ` ${this.plugin.settings.branch}` }); } if (this.plugin.settings.vaultFolder) { el.createSpan({ cls: 'ssv-info-sep', text: '·' }); - el.createSpan({ cls: 'ssv-info-item', text: `📁 ${this.plugin.settings.vaultFolder}` }); + const folderItem = el.createSpan({ cls: 'ssv-info-item' }); + setIcon(folderItem.createSpan({ cls: 'ssv-info-icon' }), ICONS.folder); + folderItem.createSpan({ text: ` ${this.plugin.settings.vaultFolder}` }); } if (this.lastSyncTime > 0) { @@ -123,12 +128,12 @@ export class SyncStatusView extends ItemView { 'remote-only': all.filter(s => s.status === 'remote-only').length, }; - const tabs: Array<{ value: FilterValue; label: string; icon: string }> = [ - { value: 'all', label: 'All', icon: '' }, - { value: 'synced', label: 'Synced', icon: '✓' }, - { value: 'modified', label: 'Changed', icon: '⚠' }, - { value: 'unsynced', label: 'Local only', icon: '↑' }, - { value: 'remote-only', label: 'Remote', icon: '↓' }, + const tabs: Array<{ value: FilterValue; label: string }> = [ + { value: 'all', label: 'All' }, + { value: 'synced', label: 'Synced' }, + { value: 'modified', label: 'Changed' }, + { value: 'unsynced', label: 'Local only' }, + { value: 'remote-only', label: 'Remote' }, ]; const tabsEl = container.createDiv({ cls: 'ssv-tabs' }); @@ -136,7 +141,10 @@ export class SyncStatusView extends ItemView { const btn = tabsEl.createEl('button', { cls: `ssv-tab${this.statusFilter === tab.value ? ' active' : ''}` }); - if (tab.icon) btn.createSpan({ text: tab.icon }); + // Share the status icon set with the file list so tabs never drift. + if (tab.value !== 'all') { + setIcon(btn.createSpan(), statusMeta(tab.value as FileStatus['status']).icon); + } btn.createSpan({ cls: 'ssv-tab-label', text: ` ${tab.label}` }); const count = counts[tab.value]; if (tab.value === 'all' || count > 0) { @@ -244,8 +252,7 @@ export class SyncStatusView extends ItemView { await this.plugin.sync.pullFile(fileStatus.file || fileStatus.path); } - // eslint-disable-next-line no-undef - await new Promise(r => activeWindow.setTimeout(r, 500)); + await new Promise(r => setTimeout(r, 500)); await this.refreshFileStatus(fileStatus.file || fileStatus.path); this.renderView(); } catch (e) { @@ -296,19 +303,21 @@ export class SyncStatusView extends ItemView { private async discoverFiles() { const allFiles = this.app.vault.getFiles(); let local = this.plugin.filterFilesByVaultFolder(allFiles); - const remoteFullPaths = await this.plugin.gitService.listFiles(this.plugin.settings.branch); + const remoteEntries = await this.plugin.gitService.listFilesDetailed(this.plugin.settings.branch); await this.plugin.gitignoreManager.loadGitignores(); - + // Map remote paths to vault paths const remoteMap = new Map(); // vaultPath -> remoteFullPath - for (const remotePath of remoteFullPaths) { - const normalized = this.getNormalizedRemotePath(remotePath); + const skipSymlinks = this.plugin.settings.symlinkHandling === 'skip'; + for (const entry of remoteEntries) { + if (entry.symlink && skipSymlinks) continue; // Symlink handling: skip + const normalized = this.getNormalizedRemotePath(entry.path); if (normalized === null) continue; // Not under rootPath - + const vaultPath = this.plugin.getVaultPath(normalized); if (!this.plugin.gitignoreManager.isIgnored(normalized)) { - remoteMap.set(vaultPath, remotePath); + remoteMap.set(vaultPath, entry.path); } } @@ -442,8 +451,9 @@ export class SyncStatusView extends ItemView { const { status, diff } = this.determineFileStatus(binary, localContent, remote); this.fileStatuses.set(path, { file, path, status, localContent, remoteContent: remote.content, remoteSha: remote.sha, diff }); - } catch { + } catch (e) { const path = typeof fileOrPath === 'string' ? fileOrPath : fileOrPath.path; + logger.warn(`Failed to determine sync status for ${path}`, e); this.fileStatuses.set(path, { file: typeof fileOrPath === 'string' ? undefined : fileOrPath, path, @@ -459,9 +469,18 @@ export class SyncStatusView extends ItemView { : await this.app.vault.adapter.read(fileOrPath as string); } if (fileOrPath instanceof TFile) { - return binary - ? await this.app.vault.readBinary(fileOrPath) - : await this.app.vault.read(fileOrPath); + try { + return binary + ? await this.app.vault.readBinary(fileOrPath) + : await this.app.vault.read(fileOrPath); + } catch (e) { + // Obsidian's cached vault.read can fail for symlinked files + // (notably on mobile); fall back to reading the path directly. + logger.warn(`vault.read failed for ${fileOrPath.path}; falling back to adapter`, e); + return binary + ? await this.app.vault.adapter.readBinary(fileOrPath.path) + : await this.app.vault.adapter.read(fileOrPath.path); + } } // This should not happen if isStr is false and fileOrPath is TFile throw new Error('Expected TFile when isStr is false'); diff --git a/src/ui/components/ActionBar.ts b/src/ui/components/ActionBar.ts index 486e19d..e802e02 100644 --- a/src/ui/components/ActionBar.ts +++ b/src/ui/components/ActionBar.ts @@ -1,4 +1,5 @@ -import { setTooltip } from 'obsidian'; +import { setIcon, setTooltip } from 'obsidian'; +import { ICONS } from './icons'; export interface ActionBarProps { hasFiles: boolean; @@ -24,15 +25,15 @@ export function renderActionBar(container: HTMLElement, props: ActionBarProps, c if (props.hasFiles) { bar.createDiv({ cls: 'ssv-bar-spacer' }); renderSelectAllRow(bar, props.allSelected, props.indeterminate, callbacks.onSelectAll); - renderLargeButton(bar, '↑', ` Push (${props.canPush})`, `Push ${props.canPush} files`, callbacks.onPush, 'push', props.canPush === 0); - renderLargeButton(bar, '↓', ` Pull (${props.canPull})`, `Pull ${props.canPull} files`, callbacks.onPull, 'pull', props.canPull === 0); - renderLargeButton(bar, '✕', ` Delete (${props.canDelete})`, `Delete ${props.canDelete} files`, callbacks.onDelete, 'danger', props.canDelete === 0); + renderLargeButton(bar, ICONS.push, ` Push (${props.canPush})`, `Push ${props.canPush} files`, callbacks.onPush, 'push', props.canPush === 0); + renderLargeButton(bar, ICONS.pull, ` Pull (${props.canPull})`, `Pull ${props.canPull} files`, callbacks.onPull, 'pull', props.canPull === 0); + renderLargeButton(bar, ICONS.delete, ` Delete (${props.canDelete})`, `Delete ${props.canDelete} files`, callbacks.onDelete, 'danger', props.canDelete === 0); } } function renderRefreshButton(bar: HTMLElement, onRefresh: () => void): void { const btn = bar.createEl('button', { cls: 'ssv-btn ssv-btn-refresh' }); - btn.createSpan({ text: '↻' }); + setIcon(btn.createSpan(), ICONS.refresh); btn.createSpan({ cls: 'ssv-btn-label', text: ' Refresh' }); setTooltip(btn, 'Refresh all statuses'); btn.addEventListener('click', onRefresh); @@ -49,7 +50,7 @@ function renderSelectAllRow(bar: HTMLElement, allSelected: boolean, indeterminat function renderLargeButton(container: HTMLElement, icon: string, label: string, tooltip: string, onClick: () => void, cls: string, disabled: boolean): void { const btn = container.createEl('button', { cls: `ssv-btn ssv-btn-${cls}` }); - btn.createSpan({ text: icon }); + setIcon(btn.createSpan(), icon); btn.createSpan({ cls: 'ssv-btn-label', text: label }); btn.disabled = disabled; setTooltip(btn, tooltip); diff --git a/src/ui/components/FileListItem.ts b/src/ui/components/FileListItem.ts index 55d4f60..2498de4 100644 --- a/src/ui/components/FileListItem.ts +++ b/src/ui/components/FileListItem.ts @@ -1,6 +1,7 @@ -import { setTooltip } from 'obsidian'; +import { setIcon, setTooltip } from 'obsidian'; import { type FileStatus } from '../types'; import { renderDiffPanel } from './DiffPanel'; +import { ICONS } from './icons'; export interface FileItemCallbacks { onSelect: (path: string, selected: boolean) => void; @@ -9,13 +10,15 @@ export interface FileItemCallbacks { onDelete: (fileStatus: FileStatus) => void; } +// `icon` is a Lucide icon id (rendered via Obsidian's setIcon) so every status +// uses the same icon set and renders consistently across platforms. export function statusMeta(status: FileStatus['status']) { switch (status) { - case 'synced': return { icon: '✓', label: 'Synced', iconCls: 'ssv-icon-synced', badgeCls: 'ssv-badge-synced', fileCls: 'status-synced' }; - case 'modified': return { icon: '⚠', label: 'Changed', iconCls: 'ssv-icon-modified', badgeCls: 'ssv-badge-modified', fileCls: 'status-modified' }; - case 'unsynced': return { icon: '↑', label: 'Local only', iconCls: 'ssv-icon-unsynced', badgeCls: 'ssv-badge-unsynced', fileCls: 'status-unsynced' }; - case 'remote-only': return { icon: '↓', label: 'Remote', iconCls: 'ssv-icon-remote', badgeCls: 'ssv-badge-remote', fileCls: 'status-remote' }; - default: return { icon: '⟳', label: 'Checking', iconCls: 'ssv-icon-checking', badgeCls: 'ssv-badge-checking', fileCls: 'status-checking' }; + case 'synced': return { icon: ICONS.synced, label: 'Synced', iconCls: 'ssv-icon-synced', badgeCls: 'ssv-badge-synced', fileCls: 'status-synced' }; + case 'modified': return { icon: ICONS.modified, label: 'Changed', iconCls: 'ssv-icon-modified', badgeCls: 'ssv-badge-modified', fileCls: 'status-modified' }; + case 'unsynced': return { icon: ICONS.push, label: 'Local only', iconCls: 'ssv-icon-unsynced', badgeCls: 'ssv-badge-unsynced', fileCls: 'status-unsynced' }; + case 'remote-only': return { icon: ICONS.pull, label: 'Remote', iconCls: 'ssv-icon-remote', badgeCls: 'ssv-badge-remote', fileCls: 'status-remote' }; + default: return { icon: ICONS.checking, label: 'Checking', iconCls: 'ssv-icon-checking', badgeCls: 'ssv-badge-checking', fileCls: 'status-checking' }; } } @@ -33,7 +36,7 @@ export function renderFileItem( cb.checked = isSelected; cb.addEventListener('change', () => callbacks.onSelect(fileStatus.path, cb.checked)); - row.createSpan({ cls: `ssv-file-icon ${iconCls}`, text: icon }); + setIcon(row.createSpan({ cls: `ssv-file-icon ${iconCls}` }), icon); row.createSpan({ cls: 'ssv-file-path', text: fileStatus.path }); row.createSpan({ cls: `ssv-status-badge ${badgeCls}`, text: label }); @@ -50,21 +53,22 @@ function renderFileActions(fileEl: HTMLElement, fileStatus: FileStatus, callback } if (fileStatus.status === 'modified' || fileStatus.status === 'unsynced') { - renderActionBtn(actions, '↑', ' Push', 'Push to remote', () => callbacks.onPush(fileStatus), 'push'); + renderActionBtn(actions, ICONS.push, ' Push', 'Push to remote', () => callbacks.onPush(fileStatus), 'push'); } if (fileStatus.status === 'modified' || fileStatus.status === 'remote-only') { - renderActionBtn(actions, '↓', ' Pull', 'Pull from remote', () => callbacks.onPull(fileStatus), 'pull'); + renderActionBtn(actions, ICONS.pull, ' Pull', 'Pull from remote', () => callbacks.onPull(fileStatus), 'pull'); } if (fileStatus.status === 'unsynced') { - renderActionBtn(actions, '✕', ' Remove', 'Delete local file', () => callbacks.onDelete(fileStatus), 'danger'); + renderActionBtn(actions, ICONS.delete, ' Remove', 'Delete local file', () => callbacks.onDelete(fileStatus), 'danger'); } } function renderDiffToggleButton(actions: HTMLElement, fileEl: HTMLElement, fileStatus: FileStatus): void { const diffBtn = actions.createEl('button', { cls: 'ssv-action-btn diff' }); - diffBtn.createSpan({ text: '≡' }); + const iconEl = diffBtn.createSpan(); + setIcon(iconEl, ICONS.diff); const btnLabel = diffBtn.createSpan({ cls: 'ssv-btn-label', text: ' Diff' }); let diffEl: HTMLElement; @@ -80,16 +84,13 @@ function renderDiffToggleButton(actions: HTMLElement, fileEl: HTMLElement, fileS const open = diffEl.hasClass('visible'); diffEl.toggleClass('visible', !open); btnLabel.setText(open ? ' Diff' : ' Hide'); - const firstChild = diffBtn.firstChild; - if (firstChild instanceof HTMLElement || firstChild instanceof Text) { - firstChild.textContent = open ? '≡' : '▴'; - } + setIcon(iconEl, open ? ICONS.diff : ICONS.diffOpen); }); } function renderActionBtn(actions: HTMLElement, icon: string, label: string, tooltip: string, onClick: () => void, cls: string): void { const btn = actions.createEl('button', { cls: `ssv-action-btn ${cls}` }); - btn.createSpan({ text: icon }); + setIcon(btn.createSpan(), icon); btn.createSpan({ cls: 'ssv-btn-label', text: label }); setTooltip(btn, tooltip); btn.addEventListener('click', onClick); diff --git a/src/ui/components/icons.ts b/src/ui/components/icons.ts new file mode 100644 index 0000000..e2b87fd --- /dev/null +++ b/src/ui/components/icons.ts @@ -0,0 +1,21 @@ +// Single source of truth for every icon used in the Sync Status view. +// +// Values are Lucide icon ids rendered through Obsidian's `setIcon`, so all +// icons share one visual style (stroke weight, size, baseline) and stay +// consistent across desktop and mobile. Reuse these constants everywhere a +// status or action icon is shown instead of inlining Unicode glyphs. +export const ICONS = { + // Status / action + synced: 'check', + modified: 'pencil', + push: 'arrow-up', + pull: 'arrow-down', + checking: 'refresh-cw', + refresh: 'refresh-cw', + delete: 'trash-2', + diff: 'file-diff', + diffOpen: 'chevron-up', + // Info strip + branch: 'git-branch', + folder: 'folder', +} as const; diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 2ccd5eb..5a91755 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -3,4 +3,5 @@ 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), + debug: (message: string, ...args: unknown[]) => console.debug(`${PREFIX} ${message}`, ...args), }; diff --git a/styles.css b/styles.css index 73075c9..c1f8f7d 100644 --- a/styles.css +++ b/styles.css @@ -295,11 +295,11 @@ } .ssv-file-icon { + display: flex; + align-items: center; + justify-content: center; width: 18px; - text-align: center; flex-shrink: 0; - font-size: 0.95em; - font-weight: 600; } .ssv-icon-synced { color: var(--color-green); } @@ -346,6 +346,9 @@ } .ssv-action-btn { + display: inline-flex; + align-items: center; + gap: 4px; padding: 4px 10px; font-size: 0.76em; border-radius: 4px; @@ -358,6 +361,29 @@ transition: background 0.1s, opacity 0.1s; } +/* Consistent sizing for all Lucide icons used in the view */ +.ssv-file-icon .svg-icon { + width: 16px; + height: 16px; +} + +.ssv-btn .svg-icon, +.ssv-tab .svg-icon, +.ssv-action-btn .svg-icon { + width: 15px; + height: 15px; +} + +.ssv-info-icon { + display: inline-flex; + align-items: center; +} + +.ssv-info-icon .svg-icon { + width: 13px; + height: 13px; +} + .is-mobile .ssv-action-btn { padding: 8px 15px; font-size: 0.85em; diff --git a/tests/logic/sync-manager-mapping.test.ts b/tests/logic/sync-manager-mapping.test.ts index 8a90ca3..bd7ffe9 100644 --- a/tests/logic/sync-manager-mapping.test.ts +++ b/tests/logic/sync-manager-mapping.test.ts @@ -52,6 +52,7 @@ const mockSettings: GitLabFilesPushSettings = { rootPath: 'notes', vaultFolder: 'Work', syncMetadata: {}, + symlinkHandling: 'real', }; describe('SyncManager Mapping', () => { diff --git a/tests/logic/sync-manager.test.ts b/tests/logic/sync-manager.test.ts index f85b6ef..58fd657 100644 --- a/tests/logic/sync-manager.test.ts +++ b/tests/logic/sync-manager.test.ts @@ -58,7 +58,8 @@ const mockSettings: GitLabFilesPushSettings = { branch: 'main', rootPath: '', syncMetadata: {}, - vaultFolder: '' + vaultFolder: '', + symlinkHandling: 'real' }; describe('SyncManager', () => { @@ -92,6 +93,26 @@ describe('SyncManager', () => { ); }); + it('falls back to the adapter when vault.read fails (e.g. symlinked file)', async () => { + const mockFile = Object.assign(new TFile(), { path: 'link.md', name: 'link.md' }); + const readSpy = vi.spyOn(mockApp.vault, 'read').mockRejectedValue(new Error('EINVAL: symlink')); + const adapterReadSpy = vi.spyOn(mockApp.vault.adapter, 'read').mockResolvedValue('linked content'); + vi.spyOn(mockGitLab, 'getFile').mockResolvedValue({ content: 'different content', sha: 'old-sha' }); + const pushSpy = vi.spyOn(mockGitLab, 'pushFile').mockResolvedValue({ path: 'link.md', sha: 'new-sha' }); + + await manager.pushFile(mockFile); + + expect(readSpy).toHaveBeenCalledWith(mockFile); + expect(adapterReadSpy).toHaveBeenCalledWith('link.md'); + expect(pushSpy).toHaveBeenCalledWith( + 'link.md', + 'linked content', + 'main', + 'Update link.md from Obsidian', + 'old-sha' + ); + }); + it('should detect conflict when remote SHA differs from last synced SHA', async () => { const mockFile = Object.assign(new TFile(), { path: 'test.md', name: 'test.md' }); diff --git a/tests/services/git-service-base.test.ts b/tests/services/git-service-base.test.ts index 8cf6a52..e5eb8ba 100644 --- a/tests/services/git-service-base.test.ts +++ b/tests/services/git-service-base.test.ts @@ -30,6 +30,42 @@ describe('BaseGitService', () => { }); }); + describe('safeRequest 404 handling', () => { + it('getFile returns empty and does not log an error on 404 (e.g. missing .gitignore)', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); + vi.mocked(requestUrl).mockResolvedValue({ + status: 404, + text: 'Not Found', + json: { message: 'Not Found' }, + } as unknown as RequestUrlResponse); + + const result = await service.getFile('missing/.gitignore', 'main'); + + expect(result).toEqual({ content: '', sha: '' }); + // A 404 is an expected "does not exist" probe: never logged as an error… + expect(errorSpy).not.toHaveBeenCalled(); + // …and logged at most once at debug level (no double-logging). + expect(debugSpy).toHaveBeenCalledTimes(1); + + errorSpy.mockRestore(); + debugSpy.mockRestore(); + }); + + it('still logs non-404 failures as errors', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.mocked(requestUrl).mockResolvedValue({ + status: 500, + text: 'Internal Server Error', + json: { message: 'Internal Server Error' }, + } as unknown as RequestUrlResponse); + + await expect(service.listFiles('main')).rejects.toThrow('500'); + expect(errorSpy).toHaveBeenCalledTimes(1); + errorSpy.mockRestore(); + }); + }); + describe('safeRequest with non-Error exception', () => { it('should wrap non-Error throws in a new Error', async () => { // Throw a plain string (not an Error instance) from requestUrl @@ -41,6 +77,71 @@ describe('BaseGitService', () => { }); }); + describe('parseJson with non-JSON (HTML) responses', () => { + // Simulates Obsidian's real RequestUrlResponse.json getter, which throws + // SyntaxError when the body is not valid JSON (e.g. an HTML page). + function htmlResponse(status = 200): RequestUrlResponse { + return { + status, + headers: { 'content-type': 'text/html; charset=utf-8' }, + text: 'Login', + get json(): unknown { + throw new SyntaxError('Unexpected token \'<\', " { + vi.mocked(requestUrl).mockResolvedValue(htmlResponse(200)); + await expect(service.getFile('test.md', 'main')).rejects.toThrow(/received an HTML page/); + // The cryptic JSON parse error must not leak through. + await expect(service.getFile('test.md', 'main')).rejects.not.toThrow(/Unexpected token/); + }); + + it('listFiles: throws a clear error when the server returns an HTML page (2xx)', async () => { + vi.mocked(requestUrl).mockResolvedValue(htmlResponse(200)); + await expect(service.listFiles('main')).rejects.toThrow(/received an HTML page/); + }); + + it('detects HTML by leading "<" even without an html content-type', async () => { + vi.mocked(requestUrl).mockResolvedValue({ + status: 200, + headers: {}, + text: ' ...', + get json(): unknown { + throw new SyntaxError("Unexpected token '<'"); + }, + } as unknown as RequestUrlResponse); + await expect(service.listFiles('main')).rejects.toThrow(/received an HTML page/); + }); + + it('reports a generic JSON parse failure for malformed (non-HTML) JSON', async () => { + vi.mocked(requestUrl).mockResolvedValue({ + status: 200, + headers: { 'content-type': 'application/json' }, + text: '{ "tree": [', + get json(): unknown { + throw new SyntaxError('Unexpected end of JSON input'); + }, + } as unknown as RequestUrlResponse); + await expect(service.listFiles('main')).rejects.toThrow(/Failed to parse the Git server response as JSON/); + }); + + it('parseErrorResponse: surfaces a clear message for an HTML error page (>=400)', async () => { + vi.mocked(requestUrl).mockResolvedValue({ + status: 502, + headers: { 'content-type': 'text/html' }, + text: 'Bad Gateway', + get json(): unknown { + throw new SyntaxError("Unexpected token '<'"); + }, + } as unknown as RequestUrlResponse); + await expect(service.listFiles('main')).rejects.toThrow(/Received an HTML page instead of a JSON error/); + // The raw HTML document must not be dumped into the message. + await expect(service.listFiles('main')).rejects.not.toThrow(/DOCTYPE/); + }); + }); + describe('encodeContent / decodeContent round-trip', () => { it('should correctly encode and decode UTF-8 content', async () => { const original = 'Hello, 世界! 🌍'; diff --git a/tests/services/github-service.test.ts b/tests/services/github-service.test.ts index 5854ea4..8fc1c44 100644 --- a/tests/services/github-service.test.ts +++ b/tests/services/github-service.test.ts @@ -70,6 +70,21 @@ describe('GitHubService', () => { expect(call.body).not.toContain('"sha":'); }); + it('should omit blank sha so creating a new file does not 422', async () => { + // A 404 lookup yields sha === '' for new files; an empty sha sent to + // GitHub causes HTTP 422, so it must be dropped from the request body. + vi.mocked(requestUrl).mockResolvedValueOnce({ + status: 201, + json: { content: { path: 'new.md', sha: 'new-sha' } } + } as unknown as RequestUrlResponse); + + const result = await service.pushFile('new.md', 'content', 'main', 'create', ''); + + expect(result).toEqual({ path: 'new.md', sha: 'new-sha' }); + const call = getLastRequestCall(); + expect(call.body).not.toContain('"sha":'); + }); + it('should update existing file correctly (sha provided)', async () => { mockRequest({ status: 200, json: { content: { path: 'existing.md', sha: 'updated-sha' } } }); @@ -101,6 +116,18 @@ describe('GitHubService', () => { expect(await service.listFiles('main')).toEqual(['vault/file1.md']); }); + it('listFilesDetailed flags symlinks (mode 120000)', async () => { + mockRequest({ status: 200, json: { tree: [ + { path: 'real.md', type: 'blob', mode: '100644' }, + { path: 'link.md', type: 'blob', mode: '120000' }, + { path: 'dir', type: 'tree', mode: '040000' }, + ] } }); + expect(await service.listFilesDetailed('main')).toEqual([ + { path: 'real.md', symlink: false }, + { path: 'link.md', symlink: true }, + ]); + }); + it('should not match sibling paths with same prefix as rootPath', async () => { service.updateConfig(token, owner, repo, 'src/content'); mockRequest({ status: 200, json: { tree: [ diff --git a/tests/services/gitlab-service.test.ts b/tests/services/gitlab-service.test.ts index d468c13..9c5d90c 100644 --- a/tests/services/gitlab-service.test.ts +++ b/tests/services/gitlab-service.test.ts @@ -50,6 +50,14 @@ describe('GitLabService', () => { expect(call.body).toContain(btoa('new content')); }); + it('should treat a blank sha as a new file (POST, no last_commit_id)', async () => { + mockRequest({ status: 201, json: { file_path: 'test.md' } }); + await service.pushFile('test.md', 'new content', 'main', 'initial commit', ''); + const call = getLastRequestCall(); + expect(call.method).toBe('POST'); + expect(call.body).not.toContain('last_commit_id'); + }); + it('should push file content correctly (PUT for existing file)', async () => { mockRequest({ status: 200, json: { file_path: 'test.md' } }); const result = await service.pushFile('test.md', 'updated content', 'main', 'update', 'old-sha'); @@ -79,6 +87,17 @@ describe('GitLabService', () => { expect(await service.listFiles('main')).toEqual(['vault/file1.md']); }); + it('listFilesDetailed flags symlinks (mode 120000)', async () => { + mockRequest({ status: 200, json: [ + { path: 'real.md', type: 'blob', mode: '100644' }, + { path: 'link.md', type: 'blob', mode: '120000' }, + ] }); + expect(await service.listFilesDetailed('main')).toEqual([ + { path: 'real.md', symlink: false }, + { path: 'link.md', symlink: true }, + ]); + }); + it('should not match sibling paths with same prefix as rootPath', async () => { service.updateConfig(baseUrl, token, projectId, 'src/content'); mockRequest({ status: 200, json: [ diff --git a/tests/setup.ts b/tests/setup.ts index 805eed1..d7f73dc 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -56,6 +56,7 @@ export const App = class { export const TFile = class {}; export const requestUrl = vi.fn(); export const setTooltip = vi.fn(); +export const setIcon = vi.fn(); vi.mock('obsidian', () => ({ Plugin, @@ -69,4 +70,5 @@ vi.mock('obsidian', () => ({ TFile, requestUrl, setTooltip, + setIcon, })); diff --git a/tests/ui/FileListItem.test.ts b/tests/ui/FileListItem.test.ts index 7983c3c..acd55dd 100644 --- a/tests/ui/FileListItem.test.ts +++ b/tests/ui/FileListItem.test.ts @@ -14,11 +14,11 @@ function makeFileStatus(status: FileStatus['status'], overrides?: Partial { it.each([ - ['synced', '✓', 'Synced', 'status-synced'], - ['modified', '⚠', 'Changed', 'status-modified'], - ['unsynced', '↑', 'Local only', 'status-unsynced'], - ['remote-only', '↓', 'Remote', 'status-remote'], - ['checking', '⟳', 'Checking', 'status-checking'], + ['synced', 'check', 'Synced', 'status-synced'], + ['modified', 'pencil', 'Changed', 'status-modified'], + ['unsynced', 'arrow-up', 'Local only', 'status-unsynced'], + ['remote-only', 'arrow-down', 'Remote', 'status-remote'], + ['checking', 'refresh-cw', 'Checking', 'status-checking'], ] as const)('%s: returns correct icon, label, and fileCls', (status, icon, label, fileCls) => { const meta = statusMeta(status); expect(meta.icon).toBe(icon); diff --git a/versions.json b/versions.json index 27c0968..ea6286f 100644 --- a/versions.json +++ b/versions.json @@ -2,5 +2,7 @@ "1.0.0": "0.15.0", "1.1.0": "1.12.7", "1.0.5": "1.12.7", - "1.0.6": "1.12.7" + "1.0.6": "1.12.7", + "1.1.1": "1.12.7", + "1.1.2": "1.12.7" } \ No newline at end of file