Skip to content

Commit b2e4fd1

Browse files
committed
Add git status badges in file explorer
1 parent 0e2ae50 commit b2e4fd1

File tree

7 files changed

+181
-5
lines changed

7 files changed

+181
-5
lines changed

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,4 @@ jobs:
3232
gh release create "$tag" \
3333
--title="$tag" \
3434
--draft \
35-
main.js manifest.json
35+
main.js manifest.json styles.css

main.js

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

src/git.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,51 @@ export async function setRemoteUrl(cwd: string, gitPath: string, url: string): P
9999
await runGit({ cwd, gitPath, args: ["remote", "add", "origin", url] });
100100
}
101101
}
102+
103+
export type FileStatus = "M" | "A" | "D" | "R" | "?" | "";
104+
105+
export async function getFileStatuses(cwd: string, gitPath: string): Promise<Map<string, FileStatus>> {
106+
const statusMap = new Map<string, FileStatus>();
107+
108+
try {
109+
const stdout = await runGit({ cwd, gitPath, args: ["status", "--porcelain=v1", "-z"] });
110+
if (!stdout) return statusMap;
111+
112+
const parts = stdout.split("\0").filter(Boolean);
113+
114+
for (let i = 0; i < parts.length; i++) {
115+
const entry = parts[i];
116+
const xy = entry.slice(0, 2);
117+
const filePath = entry.slice(3);
118+
119+
// Determine status: X is staged, Y is unstaged
120+
// We show the most relevant status
121+
let status: FileStatus = "";
122+
123+
if (xy === "??" || xy.includes("?")) {
124+
status = "A"; // Untracked = new file
125+
} else if (xy.includes("A")) {
126+
status = "A"; // Added
127+
} else if (xy.includes("D")) {
128+
status = "D"; // Deleted
129+
} else if (xy.includes("R")) {
130+
status = "R"; // Renamed
131+
} else if (xy.includes("M") || xy.includes("U")) {
132+
status = "M"; // Modified
133+
}
134+
135+
if (filePath && status) {
136+
statusMap.set(filePath, status);
137+
}
138+
139+
// Handle rename (has extra path)
140+
if (xy.startsWith("R") || xy.startsWith("C")) {
141+
i++;
142+
}
143+
}
144+
} catch {
145+
// Not a git repo or git error
146+
}
147+
148+
return statusMap;
149+
}

src/i18n.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ type Translations = {
2626
includeFileListName: string;
2727
includeFileListDesc: string;
2828

29+
showStatusBadgeName: string;
30+
showStatusBadgeDesc: string;
31+
2932
// Repository
3033
repoStatusName: string;
3134
repoNotInitialized: string;
@@ -75,6 +78,9 @@ const en: Translations = {
7578
includeFileListName: "Include file list in commit body",
7679
includeFileListDesc: "List changed files in commit message body, one per line.",
7780

81+
showStatusBadgeName: "Show git status in file explorer",
82+
showStatusBadgeDesc: "Display M (modified) / A (new) badges next to files.",
83+
7884
repoStatusName: "Repository status",
7985
repoNotInitialized: "Not a git repository",
8086
repoInitialized: "Git repository initialized",
@@ -122,6 +128,9 @@ const zhCN: Translations = {
122128
includeFileListName: "在提交正文中包含文件列表",
123129
includeFileListDesc: "在提交消息正文中列出变动的文件,每行一个。",
124130

131+
showStatusBadgeName: "在文件列表显示 Git 状态",
132+
showStatusBadgeDesc: "在文件旁显示 M(已修改)/ A(新文件)标记。",
133+
125134
repoStatusName: "仓库状态",
126135
repoNotInitialized: "尚未初始化为 Git 仓库",
127136
repoInitialized: "Git 仓库已初始化",

src/main.ts

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { EventRef, Notice, Platform, Plugin, TAbstractFile, TFile, FileSystemAdapter } from "obsidian";
22
import { AutoGitSettings, AutoGitSettingTab, DEFAULT_SETTINGS } from "./settings";
3-
import { getChangedFiles, commitAll, push } from "./git";
3+
import { getChangedFiles, commitAll, push, getFileStatuses, FileStatus } from "./git";
44
import { renderTemplate } from "./template";
55
import { t } from "./i18n";
66

@@ -11,6 +11,8 @@ export default class AutoGitPlugin extends Plugin {
1111
private isCommitting = false;
1212
private pendingRerun = false;
1313
private vaultEventRefs: EventRef[] = [];
14+
private statusRefreshInterval: number | null = null;
15+
private currentStatuses: Map<string, FileStatus> = new Map();
1416

1517
async onload() {
1618
await this.loadSettings();
@@ -34,11 +36,16 @@ export default class AutoGitPlugin extends Plugin {
3436
});
3537

3638
this.setupVaultListeners();
39+
this.setupStatusBadges();
3740
}
3841

3942
onunload() {
4043
this.clearDebounce();
4144
this.removeVaultListeners();
45+
this.clearStatusBadges();
46+
if (this.statusRefreshInterval) {
47+
window.clearInterval(this.statusRefreshInterval);
48+
}
4249
}
4350

4451
async loadSettings() {
@@ -81,6 +88,7 @@ export default class AutoGitPlugin extends Plugin {
8188
if (this.shouldIgnore(file.path)) return;
8289

8390
this.scheduleCommit();
91+
this.scheduleStatusRefresh();
8492
}
8593

8694
private shouldIgnore(path: string): boolean {
@@ -164,6 +172,9 @@ export default class AutoGitPlugin extends Plugin {
164172
if (this.settings.autoPush) {
165173
await this.doPush();
166174
}
175+
176+
// Refresh badges after commit
177+
this.refreshStatusBadges();
167178
} catch (e) {
168179
new Notice(t().noticeAutoGitError((e as Error).message));
169180
} finally {
@@ -185,4 +196,75 @@ export default class AutoGitPlugin extends Plugin {
185196
new Notice(t().noticePushFailed((e as Error).message));
186197
}
187198
}
199+
200+
// Status badge functionality
201+
private setupStatusBadges() {
202+
if (Platform.isMobileApp || !this.settings.showStatusBadge) return;
203+
204+
// Initial refresh
205+
this.refreshStatusBadges();
206+
207+
// Refresh every 5 seconds
208+
this.statusRefreshInterval = window.setInterval(() => {
209+
this.refreshStatusBadges();
210+
}, 5000);
211+
}
212+
213+
private scheduleStatusRefresh() {
214+
if (!this.settings.showStatusBadge) return;
215+
// Debounced refresh after file change
216+
window.setTimeout(() => this.refreshStatusBadges(), 500);
217+
}
218+
219+
refreshStatusBadges() {
220+
if (!this.settings.showStatusBadge) {
221+
this.clearStatusBadges();
222+
return;
223+
}
224+
225+
const cwd = this.getVaultPathSafe();
226+
if (!cwd) return;
227+
228+
getFileStatuses(cwd, this.settings.gitPath).then((statuses) => {
229+
this.currentStatuses = statuses;
230+
this.updateBadgesInDOM();
231+
});
232+
}
233+
234+
private clearStatusBadges() {
235+
document.querySelectorAll(".git-status-badge").forEach((el) => el.remove());
236+
this.currentStatuses.clear();
237+
}
238+
239+
private updateBadgesInDOM() {
240+
// Remove old badges
241+
document.querySelectorAll(".git-status-badge").forEach((el) => el.remove());
242+
243+
// Find file explorer items
244+
const fileItems = document.querySelectorAll(".nav-file-title");
245+
246+
fileItems.forEach((item) => {
247+
const pathAttr = item.getAttribute("data-path");
248+
if (!pathAttr) return;
249+
250+
const status = this.currentStatuses.get(pathAttr);
251+
if (!status) return;
252+
253+
const badge = document.createElement("span");
254+
badge.className = "git-status-badge";
255+
badge.textContent = status === "A" ? "A" : status;
256+
257+
if (status === "M") {
258+
badge.classList.add("modified");
259+
} else if (status === "A") {
260+
badge.classList.add("added");
261+
} else if (status === "D") {
262+
badge.classList.add("deleted");
263+
} else if (status === "R") {
264+
badge.classList.add("renamed");
265+
}
266+
267+
item.appendChild(badge);
268+
});
269+
}
188270
}

src/settings.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export interface AutoGitSettings {
1111
autoPush: boolean;
1212
gitPath: string;
1313
ignoreObsidianDir: boolean;
14+
showStatusBadge: boolean;
1415
}
1516

1617
export const DEFAULT_SETTINGS: AutoGitSettings = {
@@ -21,6 +22,7 @@ export const DEFAULT_SETTINGS: AutoGitSettings = {
2122
autoPush: false,
2223
gitPath: "git",
2324
ignoreObsidianDir: true,
25+
showStatusBadge: true,
2426
};
2527

2628
export class AutoGitSettingTab extends PluginSettingTab {
@@ -113,6 +115,17 @@ export class AutoGitSettingTab extends PluginSettingTab {
113115
})
114116
);
115117

118+
new Setting(containerEl)
119+
.setName(i18n.showStatusBadgeName)
120+
.setDesc(i18n.showStatusBadgeDesc)
121+
.addToggle((toggle) =>
122+
toggle.setValue(this.plugin.settings.showStatusBadge).onChange(async (value) => {
123+
this.plugin.settings.showStatusBadge = value;
124+
await this.plugin.saveSettings();
125+
this.plugin.refreshStatusBadges();
126+
})
127+
);
128+
116129
new Setting(containerEl)
117130
.setName(i18n.gitPathName)
118131
.setDesc(i18n.gitPathDesc)

styles.css

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
.git-status-badge {
2+
font-size: 10px;
3+
font-weight: bold;
4+
margin-left: 4px;
5+
padding: 0 3px;
6+
border-radius: 3px;
7+
font-family: monospace;
8+
}
9+
10+
.git-status-badge.modified {
11+
color: #e5c07b;
12+
}
13+
14+
.git-status-badge.added {
15+
color: #98c379;
16+
}
17+
18+
.git-status-badge.deleted {
19+
color: #e06c75;
20+
}
21+
22+
.git-status-badge.renamed {
23+
color: #61afef;
24+
}

0 commit comments

Comments
 (0)