Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
d38a910
feat(desktop): CodeMirrorDiffViewerに統一 + 差分表示改善
MocA-Love Mar 31, 2026
5c670d7
feat(desktop): ChangesHeaderにブランチセレクターとAI生成ボタンを追加
MocA-Love Mar 31, 2026
b5128b9
feat(desktop): ブランチ切り替え時の確認ダイアログと英語メッセージ対応
MocA-Love Mar 31, 2026
c5a5ead
feat(desktop): GitGraphビューアを実装
MocA-Love Mar 31, 2026
06b872f
feat(desktop): git blame インライン表示とホバーポップアップを追加
MocA-Love Mar 31, 2026
7b9a8f7
feat(desktop): VSCodeインラインスタイルのマージコンフリクト解消UIを追加
MocA-Love Mar 31, 2026
5deca1a
fix(desktop): GitGraphの詳細パネルがpane外にはみ出る問題を修正
MocA-Love Mar 31, 2026
431a537
fix(desktop): git blame トリップの表示タイミングを修正
MocA-Love Mar 31, 2026
fb05bc2
fix(desktop): ConflictViewerの表示・スタイル修正
MocA-Love Mar 31, 2026
f0785d8
docs: README にフォーク固有変更(#38)を追記
MocA-Love Mar 31, 2026
c51b1c4
fix: lint エラーを修正(biome-ignore・フォーマット)
MocA-Love Mar 31, 2026
96a1f6e
fix(desktop): PRレビュー指摘の修正
MocA-Love Mar 31, 2026
ef1161e
fix(desktop): CodeRabbitレビュー指摘の修正
MocA-Love Mar 31, 2026
0cfa2a5
chore: test-conflict-repoをgitignoreに追加
MocA-Love Mar 31, 2026
e4e71e1
fix(desktop): CodeRabbitレビュー指摘の修正(第2回)
MocA-Love Mar 31, 2026
0633b9e
fix(desktop): CodeRabbitレビュー指摘の修正(第3回)
MocA-Love Mar 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,4 @@ superset-dev-data/
!.codex/config.toml
!.codex/commands
!.codex/prompts
test-conflict-repo/
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ Works with any CLI agent. Built for local worktree-based development.
| **サジェストバグ修正** | ドロップダウンのはみ出し防止(上側表示切替)、alternate screen(Claude Code等)中のサジェスト完全抑制(4層防御)、Agent操作中の非表示化、日本語文字化け修正(zsh metafied エンコーディング対応) | [#31](https://github.com/MocA-Love/superset/pull/31) | 2026-03-30 |
| **サジェスト履歴削除** | サジェスト一覧の各候補にバツボタンを追加し、クリックで ~/.zsh_history から直接削除。atomic write でファイル破損防止、metafied エンコーディング対応 | [#34](https://github.com/MocA-Love/superset/pull/34) | 2026-03-30 |
| **ブラウザアドレスバー選択修正** | アドレスバーでURLをマウスドラッグで範囲選択しようとするとペインが移動する問題を修正。input の mousedown イベント伝播を阻止 | [#34](https://github.com/MocA-Love/superset/pull/34) | 2026-03-30 |
| **git blame インライン表示** | ファイルビューアで行番号横に blame 情報をインライン表示。行ホバーで作者・コミットメッセージ・日時のポップアップを表示。表示タイミングを修正し、ファイル切り替え後も正しく動作 | [#38](https://github.com/MocA-Love/superset/pull/38) | 2026-03-31 |
| **マージコンフリクト解消 UI** | diff ビューア内でコンフリクトマーカーをインラインで検出し、VSCode スタイルの「Accept Current / Accept Incoming / Accept Both」ボタンを表示。ワンクリックでコンフリクトを解消可能 | [#38](https://github.com/MocA-Love/superset/pull/38) | 2026-03-31 |
| **GitGraph 詳細パネル修正** | GitGraph の詳細パネルがペイン外にはみ出る問題を修正。パネルの位置計算を改善し、画面端でも正しく収まるよう対応 | [#38](https://github.com/MocA-Love/superset/pull/38) | 2026-03-31 |
| **ConflictViewer 表示・スタイル修正** | ConflictViewer の表示条件とスタイルを修正 | [#38](https://github.com/MocA-Love/superset/pull/38) | 2026-03-31 |

## Fork のビルド方法 (macOS)

Expand Down
113 changes: 113 additions & 0 deletions apps/desktop/src/lib/trpc/routers/changes/git-blame.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import { z } from "zod";
import { publicProcedure, router } from "../..";
import { toRegisteredWorktreeRelativePath } from "../workspace-fs-service";
import { getSimpleGitWithShellPath } from "../workspaces/utils/git-client";
import { assertRegisteredWorktree } from "./security/path-validation";

export interface BlameEntry {
line: number;
commitHash: string;
author: string;
timestamp: number;
summary: string;
}

function parseGitBlamePorcelain(output: string): BlameEntry[] {
const lines = output.split("\n");
const commitCache = new Map<
string,
{ author: string; timestamp: number; summary: string }
>();
const result: BlameEntry[] = [];

let i = 0;
while (i < lines.length) {
const header = lines[i];
if (!header || header.length < 40) {
i++;
continue;
}

const commitHash = header.substring(0, 40);
if (!/^[0-9a-f]{40}$/.test(commitHash)) {
i++;
continue;
}

const parts = header.split(" ");
const finalLine = Number.parseInt(parts[2] ?? "", 10);

i++;

let author = "";
let timestamp = 0;
let summary = "";

if (!commitCache.has(commitHash)) {
while (i < lines.length && !lines[i].startsWith("\t")) {
const line = lines[i];
if (line.startsWith("author ")) {
author = line.substring(7);
} else if (line.startsWith("author-time ")) {
timestamp = Number.parseInt(line.substring(12), 10);
} else if (line.startsWith("summary ")) {
summary = line.substring(8);
}
i++;
}
commitCache.set(commitHash, { author, timestamp, summary });
} else {
while (i < lines.length && !lines[i].startsWith("\t")) {
i++;
}
// biome-ignore lint/style/noNonNullAssertion: commitHash is guaranteed to exist in cache at this point
const cached = commitCache.get(commitHash)!;
author = cached.author;
timestamp = cached.timestamp;
summary = cached.summary;
}

// skip the tab+content line
i++;

if (!Number.isNaN(finalLine)) {
result.push({ line: finalLine, commitHash, author, timestamp, summary });
}
}

return result;
}

export const createGitBlameRouter = () => {
return router({
getGitBlame: publicProcedure
.input(
z.object({
worktreePath: z.string(),
absolutePath: z.string(),
}),
)
.query(async ({ input }): Promise<{ entries: BlameEntry[] }> => {
assertRegisteredWorktree(input.worktreePath);

const filePath = toRegisteredWorktreeRelativePath(
input.worktreePath,
input.absolutePath,
);

const git = await getSimpleGitWithShellPath(input.worktreePath);

try {
const output = await git.raw([
"blame",
"--porcelain",
"--",
filePath,
]);
return { entries: parseGitBlamePorcelain(output) };
} catch {
return { entries: [] };
}
}),
});
};
5 changes: 5 additions & 0 deletions apps/desktop/src/lib/trpc/routers/changes/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { router } from "../..";
import { createBranchesRouter } from "./branches";
import { createFileContentsRouter } from "./file-contents";
import { createGitBlameRouter } from "./git-blame";
import { createGitOperationsRouter } from "./git-operations";
import { createStagingRouter } from "./staging";
import { createStatusRouter } from "./status";
Expand All @@ -11,6 +12,7 @@ export const createChangesRouter = () => {
const fileContentsRouter = createFileContentsRouter();
const stagingRouter = createStagingRouter();
const gitOperationsRouter = createGitOperationsRouter();
const gitBlameRouter = createGitBlameRouter();

return router({
// Branch operations
Expand All @@ -27,5 +29,8 @@ export const createChangesRouter = () => {

// Git operations (commit, push, pull, sync, createPR)
...gitOperationsRouter._def.procedures,

// Git blame
...gitBlameRouter._def.procedures,
});
};
40 changes: 39 additions & 1 deletion apps/desktop/src/lib/trpc/routers/changes/status.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { TRPCError } from "@trpc/server";
import type { ChangedFile, GitChangesStatus } from "shared/changes-types";
import type {
ChangedFile,
CommitGraphData,
GitChangesStatus,
} from "shared/changes-types";
import { z } from "zod";
import { publicProcedure, router } from "../..";
import { assertRegisteredWorktree } from "./security/path-validation";
Expand Down Expand Up @@ -112,5 +116,39 @@ export const createStatusRouter = () => {
throw error;
}
}),
getCommitGraph: publicProcedure
.input(
z.object({
worktreePath: z.string(),
maxCount: z.number().int().min(1).max(5_000).optional(),
}),
Comment thread
MocA-Love marked this conversation as resolved.
)
.query(async ({ input }): Promise<CommitGraphData> => {
assertRegisteredWorktree(input.worktreePath);
const effectiveMaxCount = input.maxCount ?? 500;

try {
return await runGitTask(
"getCommitGraph",
{
worktreePath: input.worktreePath,
maxCount: effectiveMaxCount,
},
{
dedupeKey: `graph:${input.worktreePath}:${effectiveMaxCount}`,
strategy: "coalesce",
timeoutMs: 30_000,
},
);
} catch (error) {
if (error instanceof Error && error.name === "NotGitRepoError") {
throw new TRPCError({
code: "BAD_REQUEST",
message: error.message,
});
}
throw error;
}
}),
});
};
29 changes: 28 additions & 1 deletion apps/desktop/src/lib/trpc/routers/changes/utils/parse-status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,28 @@ function toChangedFile(
};
}

const CONFLICT_PAIRS = new Set([
// Only states that produce conflict markers in the file content.
// Non-marker states (DD, AU, UD, UA, DU) have no markers and are
// handled as unstaged changes via git add/rm instead.
"AA", // both added
"UU", // both modified
]);
Comment thread
MocA-Love marked this conversation as resolved.

function isConflicted(index: string, working: string): boolean {
return CONFLICT_PAIRS.has(`${index}${working}`);
}

export function parseGitStatus(
status: StatusResult,
): Pick<GitChangesStatus, "branch" | "staged" | "unstaged" | "untracked"> {
): Pick<
GitChangesStatus,
"branch" | "staged" | "unstaged" | "untracked" | "conflicted"
> {
const staged: ChangedFile[] = [];
const unstaged: ChangedFile[] = [];
const untracked: ChangedFile[] = [];
const conflicted: ChangedFile[] = [];

for (const file of status.files) {
const path = file.path;
Expand All @@ -45,6 +61,16 @@ export function parseGitStatus(
continue;
}

if (isConflicted(index, working)) {
conflicted.push({
path,
status: "modified",
additions: 0,
deletions: 0,
});
continue;
}

if (index && index !== " " && index !== "?") {
staged.push({
path,
Expand All @@ -70,6 +96,7 @@ export function parseGitStatus(
staged,
unstaged,
untracked,
conflicted,
};
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { readFile, realpath, stat } from "node:fs/promises";
import { isAbsolute, relative, resolve, sep } from "node:path";
import type { ChangedFile, GitChangesStatus } from "shared/changes-types";
import type {
ChangedFile,
CommitGraphData,
CommitGraphNode,
GitChangesStatus,
} from "shared/changes-types";
import type { SimpleGit, StatusResult } from "simple-git";
import { getStatusNoLock } from "../../workspaces/utils/git";
import { getSimpleGitWithShellPath } from "../../workspaces/utils/git-client";
Expand Down Expand Up @@ -217,6 +222,7 @@ async function computeStatus({
staged: parsed.staged,
unstaged: parsed.unstaged,
untracked: parsed.untracked,
conflicted: parsed.conflicted,
ahead: branchComparison.ahead,
behind: branchComparison.behind,
pushCount: trackingStatus.pushCount,
Expand Down Expand Up @@ -251,6 +257,70 @@ async function computeCommitFiles({
return files;
}

function parseGitGraphLog(logOutput: string): CommitGraphNode[] {
if (!logOutput.trim()) return [];

const nodes: CommitGraphNode[] = [];
const parts = logOutput.split("\x00");

for (let index = 0; index + 10 < parts.length; index += 11) {
const [
hash,
shortHash,
message,
fullMessageRaw,
author,
authorEmail,
committer,
committerEmail,
dateStr,
parentsStr,
refsStr,
] = parts.slice(index, index + 11);
if (!hash || !shortHash) continue;

const date = dateStr ? new Date(dateStr) : new Date();
const parentHashes = parentsStr?.trim() ? parentsStr.trim().split(" ") : [];
const refs = refsStr?.trim()
? refsStr.trim().split(", ").filter(Boolean)
: [];

nodes.push({
hash,
shortHash,
message: message ?? "",
fullMessage: fullMessageRaw?.trimEnd() || message || "",
author: author ?? "",
authorEmail: authorEmail ?? "",
committer: committer ?? "",
committerEmail: committerEmail ?? "",
date,
parentHashes,
refs,
});
}
return nodes;
}

async function computeCommitGraph({
worktreePath,
maxCount = 500,
}: GitTaskPayloadMap["getCommitGraph"]): Promise<CommitGraphData> {
const git = await getSimpleGitWithShellPath(worktreePath);
const logOutput = await git.raw([
"log",
"--all",
"--topo-order",
"--date-order",
"--decorate=short",
`--max-count=${maxCount}`,
"-z",
"--format=%H%x00%h%x00%s%x00%B%x00%an%x00%ae%x00%cn%x00%ce%x00%aI%x00%P%x00%D",
]);
const nodes = parseGitGraphLog(logOutput);
return { nodes };
}

export async function executeGitTask<TTask extends GitTaskType>(
taskType: TTask,
payload: GitTaskPayloadMap[TTask],
Expand All @@ -264,6 +334,10 @@ export async function executeGitTask<TTask extends GitTaskType>(
return computeCommitFiles(
payload as GitTaskPayloadMap["getCommitFiles"],
) as Promise<GitTaskResultMap[TTask]>;
case "getCommitGraph":
return computeCommitGraph(
payload as GitTaskPayloadMap["getCommitGraph"],
) as Promise<GitTaskResultMap[TTask]>;
default: {
const exhaustive: never = taskType;
throw new Error(`Unknown git task: ${exhaustive}`);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import type { ChangedFile, GitChangesStatus } from "shared/changes-types";
import type {
ChangedFile,
CommitGraphData,
GitChangesStatus,
} from "shared/changes-types";

export interface GitTaskPayloadMap {
getStatus: {
Expand All @@ -9,11 +13,16 @@ export interface GitTaskPayloadMap {
worktreePath: string;
commitHash: string;
};
getCommitGraph: {
worktreePath: string;
maxCount?: number;
};
}

export interface GitTaskResultMap {
getStatus: GitChangesStatus;
getCommitFiles: ChangedFile[];
getCommitGraph: CommitGraphData;
}

export type GitTaskType = keyof GitTaskPayloadMap;
Loading
Loading