Skip to content

Commit 153f924

Browse files
committed
perf: optimize badge updates and beforeunload behavior
- Badge DOM diffing: only update changed nodes instead of full rebuild - Add MutationObserver for file list virtualization, show badges immediately on scroll - Configurable polling interval (default 0, pure event-driven) - beforeunload uses sync commit + detached push, eliminate network blocking - Event listeners no longer depend on autoCommit setting, badge refresh works independently - Use onLayoutReady at startup to ensure DOM is ready - Remove unused commitAndPushSync function
1 parent 33a32c5 commit 153f924

File tree

5 files changed

+202
-22
lines changed

5 files changed

+202
-22
lines changed

main.js

Lines changed: 7 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/git.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { execFile, execFileSync } from "child_process";
1+
import { execFile, execFileSync, spawn } from "child_process";
22
import { promises as fs } from "fs";
33
import * as path from "path";
44

@@ -436,12 +436,27 @@ export function getChangedFilesSync(cwd: string, gitPath: string): string[] {
436436
}
437437
}
438438

439-
export function commitAndPushSync(cwd: string, gitPath: string, message: string): void {
439+
// Sync commit only, then spawn detached push process
440+
export function commitSyncAndPushDetached(cwd: string, gitPath: string, message: string): void {
440441
try {
441442
runGitSync({ cwd, gitPath, args: ["add", "-A"] });
442443
runGitSync({ cwd, gitPath, args: ["commit", "-m", message] });
443-
runGitSync({ cwd, gitPath, args: ["push"] });
444444
} catch {
445-
// Ignore errors during close
445+
// Commit failed or nothing to commit
446+
return;
447+
}
448+
449+
// Spawn detached push process that continues after parent exits
450+
try {
451+
const child = spawn(gitPath, ["push"], {
452+
cwd,
453+
detached: true,
454+
stdio: "ignore",
455+
windowsHide: true,
456+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
457+
});
458+
child.unref();
459+
} catch {
460+
// Ignore spawn errors
446461
}
447462
}

src/i18n.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@ type Translations = {
3636
showStatusBadgeName: string;
3737
showStatusBadgeDesc: string;
3838

39+
badgeRefreshIntervalName: string;
40+
badgeRefreshIntervalDesc: string;
41+
3942
showRibbonButtonName: string;
4043
showRibbonButtonDesc: string;
4144
ribbonMenuPull: string;
@@ -162,6 +165,9 @@ const en: Translations = {
162165
showStatusBadgeName: "Show git status in file explorer",
163166
showStatusBadgeDesc: "Display colored dots next to changed files and folders.",
164167

168+
badgeRefreshIntervalName: "Badge refresh interval (seconds)",
169+
badgeRefreshIntervalDesc: "Detect external git changes (e.g. terminal commands). Set to 0 if you only use Obsidian.",
170+
165171
showRibbonButtonName: "Show ribbon button",
166172
showRibbonButtonDesc: "Add a ribbon icon for quick Git actions.",
167173
ribbonMenuPull: "Pull",
@@ -283,6 +289,9 @@ const zhCN: Translations = {
283289
showStatusBadgeName: "在文件列表显示 Git 状态",
284290
showStatusBadgeDesc: "在变动的文件和文件夹旁显示彩色圆点。",
285291

292+
badgeRefreshIntervalName: "状态刷新间隔(秒)",
293+
badgeRefreshIntervalDesc: "用于检测外部 git 操作(如终端命令)。若只在 Obsidian 内操作可设为 0。",
294+
286295
showRibbonButtonName: "显示侧边栏按钮",
287296
showRibbonButtonDesc: "添加快捷 Git 操作按钮。",
288297
ribbonMenuPull: "拉取",

src/main.ts

Lines changed: 148 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { EventRef, Menu, Notice, Platform, Plugin, TAbstractFile, TFile, FileSystemAdapter } from "obsidian";
22
import { AutoGitSettings, AutoGitSettingTab, DEFAULT_SETTINGS } from "./settings";
3-
import { getChangedFiles, commitAll, push, pull, getFileStatuses, getConflictFiles, markConflictsResolved, revertAll, revertFile, FileStatus, getChangedFilesSync, commitAndPushSync } from "./git";
3+
import { getChangedFiles, commitAll, push, pull, getFileStatuses, getConflictFiles, markConflictsResolved, revertAll, revertFile, FileStatus, getChangedFilesSync, commitSyncAndPushDetached } from "./git";
44
import { renderTemplate } from "./template";
55
import { t } from "./i18n";
66
import { RevertConfirmModal } from "./modals";
@@ -15,11 +15,13 @@ export default class AutoGitPlugin extends Plugin {
1515
private statusRefreshInterval: number | null = null;
1616
private statusRefreshTimeout: number | null = null;
1717
private currentStatuses: Map<string, FileStatus> = new Map();
18+
private previousStatuses: Map<string, FileStatus> = new Map();
1819
private conflictFiles: Set<string> = new Set();
1920
private _hasConflicts = false;
2021
private resolveConflictCommand: { id: string } | null = null;
2122
private ribbonIconEl: HTMLElement | null = null;
2223
private beforeUnloadHandler: (() => void) | null = null;
24+
private mutationObserver: MutationObserver | null = null;
2325

2426
async onload() {
2527
await this.loadSettings();
@@ -56,9 +58,13 @@ export default class AutoGitPlugin extends Plugin {
5658
});
5759

5860
this.setupVaultListeners();
59-
this.updateStatusBadges();
6061
this.setupFileContextMenu();
6162

63+
// Wait for layout ready before initializing badges
64+
this.app.workspace.onLayoutReady(() => {
65+
this.updateStatusBadges();
66+
});
67+
6268
// Auto pull on open
6369
if (this.settings.autoPullOnOpen && !Platform.isMobileApp) {
6470
// Delay to ensure vault is ready
@@ -84,7 +90,8 @@ export default class AutoGitPlugin extends Plugin {
8490
if (this.settings.includeFileList) {
8591
message += "\n\n" + changedFiles.join("\n");
8692
}
87-
commitAndPushSync(cwd, this.settings.gitPath, message);
93+
// Sync commit + detached push (push runs in background after app closes)
94+
commitSyncAndPushDetached(cwd, this.settings.gitPath, message);
8895
}
8996
}
9097
}
@@ -108,6 +115,10 @@ export default class AutoGitPlugin extends Plugin {
108115
window.clearTimeout(this.statusRefreshTimeout);
109116
this.statusRefreshTimeout = null;
110117
}
118+
if (this.mutationObserver) {
119+
this.mutationObserver.disconnect();
120+
this.mutationObserver = null;
121+
}
111122
if (this.ribbonIconEl) {
112123
this.ribbonIconEl.remove();
113124
this.ribbonIconEl = null;
@@ -138,8 +149,6 @@ export default class AutoGitPlugin extends Plugin {
138149
}
139150

140151
private setupVaultListeners() {
141-
if (!this.settings.autoCommit) return;
142-
143152
if (Platform.isMobileApp) {
144153
new Notice(t().noticeMobileNotSupported);
145154
return;
@@ -157,8 +166,13 @@ export default class AutoGitPlugin extends Plugin {
157166
if (!(file instanceof TFile)) return;
158167
if (this.shouldIgnore(file.path)) return;
159168

160-
this.scheduleCommit();
169+
// Always refresh badges on file change
161170
this.scheduleStatusRefresh();
171+
172+
// Only schedule commit if autoCommit is enabled
173+
if (this.settings.autoCommit) {
174+
this.scheduleCommit();
175+
}
162176
}
163177

164178
private shouldIgnore(path: string): boolean {
@@ -450,6 +464,11 @@ export default class AutoGitPlugin extends Plugin {
450464
window.clearTimeout(this.statusRefreshTimeout);
451465
this.statusRefreshTimeout = null;
452466
}
467+
// Disconnect existing observer
468+
if (this.mutationObserver) {
469+
this.mutationObserver.disconnect();
470+
this.mutationObserver = null;
471+
}
453472

454473
if (Platform.isMobileApp || !this.settings.showStatusBadge) {
455474
this.clearStatusBadges();
@@ -459,10 +478,59 @@ export default class AutoGitPlugin extends Plugin {
459478
// Initial refresh
460479
this.refreshStatusBadges();
461480

462-
// Refresh every 5 seconds
463-
this.statusRefreshInterval = window.setInterval(() => {
464-
this.refreshStatusBadges();
465-
}, 5000);
481+
// Setup polling if interval > 0
482+
const interval = this.settings.badgeRefreshInterval;
483+
if (interval > 0) {
484+
this.statusRefreshInterval = window.setInterval(() => {
485+
this.refreshStatusBadges();
486+
}, interval * 1000);
487+
}
488+
489+
// Setup MutationObserver for virtualized file list
490+
this.setupMutationObserver();
491+
}
492+
493+
private setupMutationObserver() {
494+
// Find file explorer container
495+
const container = document.querySelector(".nav-files-container");
496+
if (!container) return;
497+
498+
this.mutationObserver = new MutationObserver((mutations) => {
499+
for (const mutation of mutations) {
500+
mutation.addedNodes.forEach((node) => {
501+
if (node instanceof HTMLElement) {
502+
this.applyBadgesToNewNodes(node);
503+
}
504+
});
505+
}
506+
});
507+
508+
this.mutationObserver.observe(container, {
509+
childList: true,
510+
subtree: true,
511+
});
512+
}
513+
514+
private applyBadgesToNewNodes(node: HTMLElement) {
515+
// Check if node itself is a file/folder title
516+
const items = node.matches(".nav-file-title, .nav-folder-title")
517+
? [node]
518+
: Array.from(node.querySelectorAll(".nav-file-title, .nav-folder-title"));
519+
520+
const allStatuses = this.getMergedStatuses();
521+
522+
for (const item of items) {
523+
const path = item.getAttribute("data-path");
524+
if (!path) continue;
525+
526+
// Skip if already has badge
527+
if (item.querySelector(".git-status-badge")) continue;
528+
529+
const status = allStatuses.get(path);
530+
if (status) {
531+
this.addBadgeToElement(item, status);
532+
}
533+
}
466534
}
467535

468536
private scheduleStatusRefresh() {
@@ -488,7 +556,12 @@ export default class AutoGitPlugin extends Plugin {
488556

489557
getFileStatuses(cwd, this.settings.gitPath).then((statuses) => {
490558
this.currentStatuses = statuses;
491-
this.updateBadgesInDOM();
559+
// Use full update if first time, otherwise use diff
560+
if (this.previousStatuses.size === 0) {
561+
this.updateBadgesInDOM();
562+
} else {
563+
this.updateBadgesDiff();
564+
}
492565
}).catch(() => {
493566
// Ignore errors
494567
});
@@ -497,6 +570,67 @@ export default class AutoGitPlugin extends Plugin {
497570
private clearStatusBadges() {
498571
document.querySelectorAll(".git-status-badge").forEach((el) => el.remove());
499572
this.currentStatuses.clear();
573+
this.previousStatuses.clear();
574+
}
575+
576+
private getMergedStatuses(): Map<string, FileStatus> {
577+
const merged = new Map(this.currentStatuses);
578+
this.conflictFiles.forEach((file) => {
579+
merged.set(file, "U" as FileStatus);
580+
});
581+
582+
// Add folder statuses
583+
const folderStatuses = this.calculateFolderStatuses(merged);
584+
folderStatuses.forEach((status, path) => {
585+
merged.set(path, status);
586+
});
587+
588+
return merged;
589+
}
590+
591+
private updateBadgesDiff() {
592+
const newStatuses = this.getMergedStatuses();
593+
594+
// Find paths to remove (in previous but not in new, or status changed)
595+
for (const [path, oldStatus] of this.previousStatuses) {
596+
const newStatus = newStatuses.get(path);
597+
if (!newStatus || newStatus !== oldStatus) {
598+
this.removeBadgeFromPath(path);
599+
}
600+
}
601+
602+
// Find paths to add/update (in new but not in previous, or status changed)
603+
for (const [path, status] of newStatuses) {
604+
const oldStatus = this.previousStatuses.get(path);
605+
if (oldStatus !== status) {
606+
this.updateBadgeForPath(path, status);
607+
}
608+
}
609+
610+
this.previousStatuses = newStatuses;
611+
}
612+
613+
private removeBadgeFromPath(path: string) {
614+
const escapedPath = CSS.escape(path);
615+
const selector = `.nav-file-title[data-path="${escapedPath}"], .nav-folder-title[data-path="${escapedPath}"]`;
616+
const el = document.querySelector(selector);
617+
if (el) {
618+
const badge = el.querySelector(".git-status-badge");
619+
if (badge) badge.remove();
620+
}
621+
}
622+
623+
private updateBadgeForPath(path: string, status: FileStatus) {
624+
const escapedPath = CSS.escape(path);
625+
const selector = `.nav-file-title[data-path="${escapedPath}"], .nav-folder-title[data-path="${escapedPath}"]`;
626+
const el = document.querySelector(selector);
627+
if (el) {
628+
// Remove existing badge if any
629+
const existingBadge = el.querySelector(".git-status-badge");
630+
if (existingBadge) existingBadge.remove();
631+
// Add new badge
632+
this.addBadgeToElement(el, status);
633+
}
500634
}
501635

502636
private updateBadgesInDOM() {
@@ -533,6 +667,9 @@ export default class AutoGitPlugin extends Plugin {
533667
this.addBadgeToElement(item, status);
534668
}
535669
});
670+
671+
// Update previous statuses for diff tracking
672+
this.previousStatuses = this.getMergedStatuses();
536673
}
537674

538675
private calculateFolderStatuses(statuses: Map<string, FileStatus>): Map<string, FileStatus> {

src/settings.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export interface AutoGitSettings {
1515
ignoreObsidianDir: boolean;
1616
showStatusBadge: boolean;
1717
showRibbonButton: boolean;
18+
badgeRefreshInterval: number; // 0 = disabled, otherwise seconds
1819
}
1920

2021
export const DEFAULT_SETTINGS: AutoGitSettings = {
@@ -29,6 +30,7 @@ export const DEFAULT_SETTINGS: AutoGitSettings = {
2930
ignoreObsidianDir: true,
3031
showStatusBadge: true,
3132
showRibbonButton: true,
33+
badgeRefreshInterval: 0, // 0 = disabled (event-driven only), otherwise seconds
3234
};
3335

3436
export class AutoGitSettingTab extends PluginSettingTab {
@@ -164,6 +166,23 @@ export class AutoGitSettingTab extends PluginSettingTab {
164166
})
165167
);
166168

169+
new Setting(containerEl)
170+
.setName(i18n.badgeRefreshIntervalName)
171+
.setDesc(i18n.badgeRefreshIntervalDesc)
172+
.addText((text) =>
173+
text
174+
.setPlaceholder("10")
175+
.setValue(String(this.plugin.settings.badgeRefreshInterval))
176+
.onChange(async (value) => {
177+
const num = parseInt(value);
178+
if (!isNaN(num) && num >= 0) {
179+
this.plugin.settings.badgeRefreshInterval = num;
180+
await this.plugin.saveSettings();
181+
this.plugin.updateStatusBadges();
182+
}
183+
})
184+
);
185+
167186
new Setting(containerEl)
168187
.setName(i18n.showRibbonButtonName)
169188
.setDesc(i18n.showRibbonButtonDesc)

0 commit comments

Comments
 (0)