Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ Works with any CLI agent. Built for local worktree-based development.
| **AI コミットメッセージ生成** | コミットメッセージ入力欄のスパークルボタンで AI が conventional commit メッセージを日本語で自動生成。階層的要約方式(gptcommit 式)により大量差分でも高精度。staged/unstaged/untracked 全対応、lock ファイル・バイナリ自動スキップ | [#4](https://github.com/MocA-Love/superset/pull/4) | 2026-03-28 |
| **ポートリストのリサイズ・フィルタ** | サイドバーの Ports セクションの高さをドラッグでリサイズ可能に(80–600px、永続化)。フィルタトグルで ports.json に定義されたポートのみ表示し、自動検出ポートを非表示にできる | [#6](https://github.com/MocA-Love/superset/pull/6) | 2026-03-28 |
| **大規模ファイル diff 高速化** | 2000行超のファイルで CodeMirror 6 ベースの仮想化 diff ビューアに自動切替。ビューポート分のDOMのみ描画し、15000行でもスムーズ表示。既存テーマ・シンタックスハイライト再利用、未変更領域の自動折りたたみ | [#5](https://github.com/MocA-Love/superset/pull/5) | 2026-03-28 |
| **ports.json ポートの常時表示** | ports.json に定義されたポートをプロセス検出の有無にかかわらず常にサイドバーに表示。Docker 等で検知できないポートもラベル付きで一覧に出る。検出済みポートは従来通りアクティブ表示、未検出は グレー表示で区別 | [#7](https://github.com/MocA-Love/superset/pull/7) | 2026-03-28 |

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

Expand Down
85 changes: 67 additions & 18 deletions apps/desktop/src/lib/trpc/routers/ports/ports.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { workspaces } from "@superset/local-db";
import { observable } from "@trpc/server/observable";
import { eq } from "drizzle-orm";
import { localDb } from "main/lib/local-db";
import { loadStaticPorts } from "main/lib/static-ports";
import { portManager } from "main/lib/terminal/port-manager";
Expand All @@ -24,30 +23,80 @@ function getLabelsForPath(worktreePath: string): Map<number, string> | null {
return labels;
}

/** Cache structure for workspace path + labels lookup. */
interface WorkspaceLabelInfo {
labels: Map<number, string> | null;
workspaceId: string;
}

function buildLabelCache(): Map<string, WorkspaceLabelInfo> {
const cache = new Map<string, WorkspaceLabelInfo>();
const allWs = localDb.select().from(workspaces).all();

for (const ws of allWs) {
const wsPath = getWorkspacePath(ws);
if (!wsPath) continue;
const labels = getLabelsForPath(wsPath);
if (labels) {
cache.set(ws.id, { labels, workspaceId: ws.id });
}
}

return cache;
}

export const createPortsRouter = () => {
return router({
getAll: publicProcedure.query((): EnrichedPort[] => {
const detectedPorts = portManager.getAllPorts();
const labelCache = buildLabelCache();

const labelCache = new Map<string, Map<number, string> | null>();

return detectedPorts.map((port) => {
if (!labelCache.has(port.workspaceId)) {
const ws = localDb
.select()
.from(workspaces)
.where(eq(workspaces.id, port.workspaceId))
.get();
const wsPath = ws ? getWorkspacePath(ws) : null;
labelCache.set(
port.workspaceId,
wsPath ? getLabelsForPath(wsPath) : null,
);
}
// Track which static ports have been matched with detected ports
// key: "workspaceId:port"
const matchedStaticPorts = new Set<string>();

const labels = labelCache.get(port.workspaceId);
return { ...port, label: labels?.get(port.port) ?? null };
// Enrich detected ports with labels
const enriched: EnrichedPort[] = detectedPorts.map((port) => {
const info = labelCache.get(port.workspaceId);
const label = info?.labels.get(port.port) ?? null;
if (label != null) {
matchedStaticPorts.add(`${port.workspaceId}:${port.port}`);
}
return {
port: port.port,
workspaceId: port.workspaceId,
label,
detected: true,
pid: port.pid,
processName: port.processName,
paneId: port.paneId,
detectedAt: port.detectedAt,
address: port.address,
};
});

// Add static ports that were NOT detected
for (const [wsId, info] of labelCache) {
if (!info.labels) continue;
for (const [portNum, label] of info.labels) {
const key = `${wsId}:${portNum}`;
if (matchedStaticPorts.has(key)) continue;

enriched.push({
port: portNum,
workspaceId: wsId,
label,
detected: false,
pid: null,
processName: null,
paneId: null,
detectedAt: null,
address: null,
});
}
}

return enriched;
}),

subscribe: publicProcedure.subscription(() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,14 @@ export function MergedPortBadge({ port }: MergedPortBadgeProps) {
const openUrl = electronTrpc.external.openUrl.useMutation();
const { killPort } = useKillPort();

const isDetected = port.detected;

const displayContent = port.label ? (
<>
{port.label}{" "}
<span className="font-mono font-normal text-muted-foreground">
<span
className={`font-mono font-normal ${isDetected ? "text-muted-foreground" : "text-muted-foreground/50"}`}
>
{port.port}
</span>
</>
Expand Down Expand Up @@ -65,7 +69,13 @@ export function MergedPortBadge({ port }: MergedPortBadgeProps) {
return (
<Tooltip>
<TooltipTrigger asChild>
<div className="group relative inline-flex items-center gap-1 rounded-md text-xs transition-colors mb-1 bg-primary/10 text-primary hover:bg-primary/20">
<div
className={`group relative inline-flex items-center gap-1 rounded-md text-xs transition-colors mb-1 ${
isDetected
? "bg-primary/10 text-primary hover:bg-primary/20"
: "bg-muted/40 text-muted-foreground/60"
}`}
>
<button
type="button"
onClick={handleClick}
Expand All @@ -74,22 +84,29 @@ export function MergedPortBadge({ port }: MergedPortBadgeProps) {
>
{displayContent}
</button>
<button
type="button"
onClick={handleOpenInBrowser}
aria-label={`Open ${port.label || `port ${port.port}`} in browser`}
className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-primary focus-visible:opacity-100 focus-visible:outline-none"
>
<LuExternalLink className="size-3.5" strokeWidth={STROKE_WIDTH} />
</button>
<button
type="button"
onClick={handleClose}
aria-label={`Close ${port.label || `port ${port.port}`}`}
className="opacity-0 group-hover:opacity-100 pr-1 transition-opacity text-muted-foreground hover:text-primary focus-visible:opacity-100 focus-visible:outline-none"
>
<LuX className="size-3.5" strokeWidth={STROKE_WIDTH} />
</button>
{isDetected && (
<>
<button
type="button"
onClick={handleOpenInBrowser}
aria-label={`Open ${port.label || `port ${port.port}`} in browser`}
className="opacity-0 group-hover:opacity-100 transition-opacity text-muted-foreground hover:text-primary focus-visible:opacity-100 focus-visible:outline-none"
>
<LuExternalLink
className="size-3.5"
strokeWidth={STROKE_WIDTH}
/>
</button>
<button
type="button"
onClick={handleClose}
aria-label={`Close ${port.label || `port ${port.port}`}`}
className="opacity-0 group-hover:opacity-100 pr-1 transition-opacity text-muted-foreground hover:text-primary focus-visible:opacity-100 focus-visible:outline-none"
>
<LuX className="size-3.5" strokeWidth={STROKE_WIDTH} />
</button>
</>
)}
</div>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6} showArrow={false}>
Expand All @@ -100,12 +117,17 @@ export function MergedPortBadge({ port }: MergedPortBadgeProps) {
>
localhost:{port.port}
</div>
{(port.processName || port.pid != null) && (
{isDetected && (port.processName || port.pid != null) && (
<div className="text-muted-foreground">
{port.processName}
{port.pid != null && ` (pid ${port.pid})`}
</div>
)}
{!isDetected && (
<div className="text-muted-foreground/70 text-[10px]">
Not detected
</div>
)}
{canJumpToTerminal && (
<div className="text-muted-foreground/70 text-[10px]">
Click to open workspace
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ export function WorkspacePortGroup({ group }: WorkspacePortGroupProps) {
navigateToWorkspace(group.workspaceId, navigate);
};

const detectedPorts = group.ports.filter((p) => p.detected);

const handleCloseAll = () => {
killPorts(group.ports);
killPorts(detectedPorts);
};

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export function useKillPort() {
const killMutation = electronTrpc.ports.kill.useMutation();

const killPort = async (port: EnrichedPort) => {
if (!port.paneId) return;
const result = await killMutation.mutateAsync({
paneId: port.paneId,
port: port.port,
Expand All @@ -18,12 +19,13 @@ export function useKillPort() {
};

const killPorts = async (ports: EnrichedPort[]) => {
if (ports.length === 0) return;
const killable = ports.filter((p) => p.paneId != null);
if (killable.length === 0) return;

const results = await Promise.all(
ports.map((port) =>
killable.map((port) =>
killMutation.mutateAsync({
paneId: port.paneId,
paneId: port.paneId as string,
port: port.port,
}),
),
Expand Down
12 changes: 11 additions & 1 deletion apps/desktop/src/shared/types/ports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,16 @@ export interface StaticPortsResult {
error: string | null;
}

export interface EnrichedPort extends DetectedPort {
export interface EnrichedPort {
port: number;
workspaceId: string;
label: string | null;
/** Whether this port is currently detected as listening. */
detected: boolean;
/** Detection info — only present when `detected` is true. */
pid: number | null;
processName: string | null;
paneId: string | null;
detectedAt: number | null;
address: string | null;
}