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)

*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