mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
feat: display repo and branch info in plan document (#117)
* feat: display repo and branch info in plan document Add subtle badge display in upper-left corner of plan document showing: - Repository name (org/repo from git remote, with fallbacks) - Current branch with git branch icon New modular repo.ts handles detection with fallback chain: 1. Parse org/repo from git remote origin 2. Fall back to git repo root directory name 3. Fall back to current working directory Closes #104 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * test: add --no-git flag to sandbox script Allows testing repo info fallback behavior when not in a git repository. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -347,6 +347,7 @@ const App: React.FC = () => {
|
||||
const [showPermissionModeSetup, setShowPermissionModeSetup] = useState(false);
|
||||
const [permissionMode, setPermissionMode] = useState<PermissionMode>('bypassPermissions');
|
||||
const [sharingEnabled, setSharingEnabled] = useState(true);
|
||||
const [repoInfo, setRepoInfo] = useState<{ display: string; branch?: string } | null>(null);
|
||||
const viewerRef = useRef<ViewerHandle>(null);
|
||||
|
||||
// URL-based sharing
|
||||
@@ -404,12 +405,15 @@ const App: React.FC = () => {
|
||||
if (!res.ok) throw new Error('Not in API mode');
|
||||
return res.json();
|
||||
})
|
||||
.then((data: { plan: string; origin?: 'claude-code' | 'opencode'; sharingEnabled?: boolean }) => {
|
||||
.then((data: { plan: string; origin?: 'claude-code' | 'opencode'; sharingEnabled?: boolean; repoInfo?: { display: string; branch?: string } }) => {
|
||||
setMarkdown(data.plan);
|
||||
setIsApiMode(true);
|
||||
if (data.sharingEnabled !== undefined) {
|
||||
setSharingEnabled(data.sharingEnabled);
|
||||
}
|
||||
if (data.repoInfo) {
|
||||
setRepoInfo(data.repoInfo);
|
||||
}
|
||||
if (data.origin) {
|
||||
setOrigin(data.origin);
|
||||
// For Claude Code, check if user needs to configure permission mode
|
||||
@@ -814,6 +818,7 @@ const App: React.FC = () => {
|
||||
globalAttachments={globalAttachments}
|
||||
onAddGlobalAttachment={handleAddGlobalAttachment}
|
||||
onRemoveGlobalAttachment={handleRemoveGlobalAttachment}
|
||||
repoInfo={repoInfo}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -150,6 +150,7 @@ const ReviewApp: React.FC = () => {
|
||||
const [submitted, setSubmitted] = useState<'approved' | 'feedback' | false>(false);
|
||||
const [showApproveWarning, setShowApproveWarning] = useState(false);
|
||||
const [sharingEnabled, setSharingEnabled] = useState(true);
|
||||
const [repoInfo, setRepoInfo] = useState<{ display: string; branch?: string } | null>(null);
|
||||
|
||||
const identity = useMemo(() => getIdentity(), []);
|
||||
|
||||
@@ -203,6 +204,7 @@ const ReviewApp: React.FC = () => {
|
||||
diffType?: string;
|
||||
gitContext?: GitContext;
|
||||
sharingEnabled?: boolean;
|
||||
repoInfo?: { display: string; branch?: string };
|
||||
}) => {
|
||||
const apiFiles = parseDiffToFiles(data.rawPatch);
|
||||
setDiffData({
|
||||
@@ -219,6 +221,7 @@ const ReviewApp: React.FC = () => {
|
||||
if (data.diffType) setDiffType(data.diffType);
|
||||
if (data.gitContext) setGitContext(data.gitContext);
|
||||
if (data.sharingEnabled !== undefined) setSharingEnabled(data.sharingEnabled);
|
||||
if (data.repoInfo) setRepoInfo(data.repoInfo);
|
||||
})
|
||||
.catch(() => {
|
||||
// Not in API mode - use demo content
|
||||
@@ -523,6 +526,14 @@ const ReviewApp: React.FC = () => {
|
||||
{origin === 'claude-code' ? 'Claude Code' : 'OpenCode'}
|
||||
</span>
|
||||
)}
|
||||
{repoInfo && (
|
||||
<>
|
||||
<span className="text-muted-foreground/40 hidden md:inline">|</span>
|
||||
<span className="text-xs text-muted-foreground/60 font-mono hidden md:inline truncate max-w-[200px]" title={repoInfo.display}>
|
||||
{repoInfo.display}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 md:gap-2">
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
saveAnnotations,
|
||||
saveFinalSnapshot,
|
||||
} from "./storage";
|
||||
import { getRepoInfo } from "./repo";
|
||||
|
||||
// Re-export utilities
|
||||
export { isRemoteSession, getServerPort } from "./remote";
|
||||
@@ -99,6 +100,9 @@ export async function startPlannotatorServer(
|
||||
// Generate slug for potential saving (actual save happens on decision)
|
||||
const slug = generateSlug(plan);
|
||||
|
||||
// Detect repo info (cached for this session)
|
||||
const repoInfo = await getRepoInfo();
|
||||
|
||||
// Decision promise
|
||||
let resolveDecision: (result: {
|
||||
approved: boolean;
|
||||
@@ -130,7 +134,7 @@ export async function startPlannotatorServer(
|
||||
|
||||
// API: Get plan content
|
||||
if (url.pathname === "/api/plan") {
|
||||
return Response.json({ plan, origin, permissionMode, sharingEnabled });
|
||||
return Response.json({ plan, origin, permissionMode, sharingEnabled, repoInfo });
|
||||
}
|
||||
|
||||
// API: Serve images (local paths or temp uploads)
|
||||
|
||||
@@ -11,7 +11,8 @@
|
||||
"./remote": "./remote.ts",
|
||||
"./browser": "./browser.ts",
|
||||
"./storage": "./storage.ts",
|
||||
"./git": "./git.ts"
|
||||
"./git": "./git.ts",
|
||||
"./repo": "./repo.ts"
|
||||
},
|
||||
"files": [
|
||||
"*.ts"
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Repository Info Detection
|
||||
*
|
||||
* Detects org/repo from git remote with fallback chain.
|
||||
* Priority: org/repo from remote → repo name → directory name
|
||||
*/
|
||||
|
||||
import { $ } from "bun";
|
||||
|
||||
export interface RepoInfo {
|
||||
/** Display string (e.g., "backnotprop/plannotator" or "my-project") */
|
||||
display: string;
|
||||
/** Current git branch (if in a git repo) */
|
||||
branch?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse org/repo from a git remote URL
|
||||
*
|
||||
* Handles:
|
||||
* - SSH: git@github.com:org/repo.git
|
||||
* - HTTPS: https://github.com/org/repo.git
|
||||
* - SSH with port: ssh://git@github.com:22/org/repo.git
|
||||
*/
|
||||
function parseRemoteUrl(url: string): string | null {
|
||||
if (!url) return null;
|
||||
|
||||
// SSH format: git@github.com:org/repo.git
|
||||
const sshMatch = url.match(/:([^/]+\/[^/]+?)(?:\.git)?$/);
|
||||
if (sshMatch) return sshMatch[1];
|
||||
|
||||
// HTTPS format: https://github.com/org/repo.git
|
||||
const httpsMatch = url.match(/\/([^/]+\/[^/]+?)(?:\.git)?$/);
|
||||
if (httpsMatch) return httpsMatch[1];
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get directory name from path
|
||||
*/
|
||||
function getDirName(path: string): string | null {
|
||||
if (!path) return null;
|
||||
const trimmed = path.trim().replace(/\/+$/, "");
|
||||
const parts = trimmed.split("/");
|
||||
return parts[parts.length - 1] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current git branch
|
||||
*/
|
||||
async function getCurrentBranch(): Promise<string | undefined> {
|
||||
try {
|
||||
const result = await $`git rev-parse --abbrev-ref HEAD`.quiet().nothrow();
|
||||
if (result.exitCode === 0) {
|
||||
const branch = result.stdout.toString().trim();
|
||||
return branch && branch !== "HEAD" ? branch : undefined;
|
||||
}
|
||||
} catch {
|
||||
// Not in a git repo
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect repository info with fallback chain
|
||||
*
|
||||
* 1. Try org/repo from git remote origin
|
||||
* 2. Fall back to git repo root directory name
|
||||
* 3. Fall back to current working directory name
|
||||
*/
|
||||
export async function getRepoInfo(): Promise<RepoInfo | null> {
|
||||
let branch: string | undefined;
|
||||
|
||||
// Try git remote URL first
|
||||
try {
|
||||
const result = await $`git remote get-url origin`.quiet().nothrow();
|
||||
if (result.exitCode === 0) {
|
||||
const orgRepo = parseRemoteUrl(result.stdout.toString().trim());
|
||||
if (orgRepo) {
|
||||
branch = await getCurrentBranch();
|
||||
return { display: orgRepo, branch };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Git not available
|
||||
}
|
||||
|
||||
// Fallback: git repo root name
|
||||
try {
|
||||
const result = await $`git rev-parse --show-toplevel`.quiet().nothrow();
|
||||
if (result.exitCode === 0) {
|
||||
const repoName = getDirName(result.stdout.toString());
|
||||
if (repoName) {
|
||||
branch = await getCurrentBranch();
|
||||
return { display: repoName, branch };
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not in a git repo
|
||||
}
|
||||
|
||||
// Final fallback: current directory (no branch - not a git repo)
|
||||
try {
|
||||
const dirName = getDirName(process.cwd());
|
||||
if (dirName) {
|
||||
return { display: dirName };
|
||||
}
|
||||
} catch {
|
||||
// Shouldn't happen
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { mkdirSync } from "fs";
|
||||
import { isRemoteSession, getServerPort } from "./remote";
|
||||
import { openBrowser } from "./browser";
|
||||
import { type DiffType, type GitContext, runGitDiff } from "./git";
|
||||
import { getRepoInfo } from "./repo";
|
||||
|
||||
// Re-export utilities
|
||||
export { isRemoteSession, getServerPort } from "./remote";
|
||||
@@ -89,6 +90,9 @@ export async function startReviewServer(
|
||||
const isRemote = isRemoteSession();
|
||||
const configuredPort = getServerPort();
|
||||
|
||||
// Detect repo info (cached for this session)
|
||||
const repoInfo = await getRepoInfo();
|
||||
|
||||
// Decision promise
|
||||
let resolveDecision: (result: {
|
||||
feedback: string;
|
||||
@@ -123,6 +127,7 @@ export async function startReviewServer(
|
||||
diffType: currentDiffType,
|
||||
gitContext,
|
||||
sharingEnabled,
|
||||
repoInfo,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ interface ViewerProps {
|
||||
globalAttachments?: string[];
|
||||
onAddGlobalAttachment?: (path: string) => void;
|
||||
onRemoveGlobalAttachment?: (path: string) => void;
|
||||
repoInfo?: { display: string; branch?: string } | null;
|
||||
}
|
||||
|
||||
export interface ViewerHandle {
|
||||
@@ -102,6 +103,7 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
||||
globalAttachments = [],
|
||||
onAddGlobalAttachment,
|
||||
onRemoveGlobalAttachment,
|
||||
repoInfo,
|
||||
}, ref) => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [showGlobalCommentInput, setShowGlobalCommentInput] = useState(false);
|
||||
@@ -628,7 +630,24 @@ export const Viewer = forwardRef<ViewerHandle, ViewerProps>(({
|
||||
ref={containerRef}
|
||||
className="w-full max-w-[832px] 2xl:max-w-5xl bg-card border border-border/50 rounded-xl shadow-xl p-5 md:p-8 lg:p-10 xl:p-12 relative"
|
||||
>
|
||||
{/* Header buttons */}
|
||||
{/* Repo info - top left */}
|
||||
{repoInfo && (
|
||||
<div className="absolute top-3 left-3 md:top-4 md:left-5 flex items-center gap-1.5 text-[9px] text-muted-foreground/50 font-mono">
|
||||
<span className="px-1.5 py-0.5 bg-muted/50 rounded truncate max-w-[140px]" title={repoInfo.display}>
|
||||
{repoInfo.display}
|
||||
</span>
|
||||
{repoInfo.branch && (
|
||||
<span className="px-1.5 py-0.5 bg-muted/30 rounded max-w-[120px] flex items-center gap-1 overflow-hidden" title={repoInfo.branch}>
|
||||
<svg className="w-2.5 h-2.5 flex-shrink-0" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628A2.25 2.25 0 0 1 9.5 3.25Zm-6 0a.75.75 0 1 0 1.5 0 .75.75 0 0 0-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z" />
|
||||
</svg>
|
||||
<span className="truncate">{repoInfo.branch}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Header buttons - top right */}
|
||||
<div className="absolute top-3 right-3 md:top-5 md:right-5 flex items-start gap-2">
|
||||
{/* Attachments button */}
|
||||
{onAddGlobalAttachment && onRemoveGlobalAttachment && (
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
# Sandbox script for testing Plannotator OpenCode plugin locally
|
||||
#
|
||||
# Usage:
|
||||
# ./sandbox-opencode.sh [--disable-sharing] [--keep]
|
||||
# ./sandbox-opencode.sh [--disable-sharing] [--keep] [--no-git]
|
||||
#
|
||||
# Options:
|
||||
# --disable-sharing Create opencode.json with "share": "disabled" to test
|
||||
# the sharing disable feature without env var pollution
|
||||
# --keep Don't clean up sandbox on exit (for debugging)
|
||||
# --no-git Don't initialize git repo (tests non-git fallback)
|
||||
#
|
||||
# What it does:
|
||||
# 1. Builds the plugin (ensures latest code)
|
||||
@@ -29,6 +30,7 @@ PLUGIN_DIR="$PROJECT_ROOT/apps/opencode-plugin"
|
||||
# Parse CLI flags
|
||||
DISABLE_SHARING=false
|
||||
KEEP_SANDBOX=false
|
||||
NO_GIT=false
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--disable-sharing)
|
||||
@@ -39,6 +41,10 @@ for arg in "$@"; do
|
||||
KEEP_SANDBOX=true
|
||||
shift
|
||||
;;
|
||||
--no-git)
|
||||
NO_GIT=true
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
@@ -70,11 +76,13 @@ cleanup() {
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Initialize git repo
|
||||
# Initialize git repo (unless --no-git)
|
||||
cd "$SANDBOX_DIR"
|
||||
git init -q
|
||||
git config user.email "test@example.com"
|
||||
git config user.name "Test User"
|
||||
if [ "$NO_GIT" = false ]; then
|
||||
git init -q
|
||||
git config user.email "test@example.com"
|
||||
git config user.name "Test User"
|
||||
fi
|
||||
|
||||
# Create initial project structure
|
||||
mkdir -p src/{api,components,hooks,utils,types}
|
||||
@@ -708,8 +716,10 @@ cat > .opencode/package.json << 'EOF'
|
||||
}
|
||||
EOF
|
||||
|
||||
git add .
|
||||
git commit -q -m "Initial commit: Task manager app"
|
||||
if [ "$NO_GIT" = false ]; then
|
||||
git add .
|
||||
git commit -q -m "Initial commit: Task manager app"
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# Make uncommitted changes (simulating a feature branch with multiple changes)
|
||||
@@ -1517,8 +1527,12 @@ describe('formatters', () => {
|
||||
EOF
|
||||
|
||||
echo ""
|
||||
echo "Git status (uncommitted changes for /plannotator-review):"
|
||||
git diff --stat
|
||||
if [ "$NO_GIT" = false ]; then
|
||||
echo "Git status (uncommitted changes for /plannotator-review):"
|
||||
git diff --stat
|
||||
else
|
||||
echo "Git: DISABLED (--no-git flag)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Set up local plugin via loader file
|
||||
@@ -1555,6 +1569,11 @@ fi
|
||||
echo "=== Sandbox Ready ==="
|
||||
echo ""
|
||||
echo "Directory: $SANDBOX_DIR"
|
||||
if [ "$NO_GIT" = true ]; then
|
||||
echo "Git: DISABLED (--no-git)"
|
||||
else
|
||||
echo "Git: enabled"
|
||||
fi
|
||||
if [ "$DISABLE_SHARING" = true ]; then
|
||||
echo "Sharing: DISABLED (via opencode.json config)"
|
||||
else
|
||||
@@ -1563,7 +1582,9 @@ fi
|
||||
echo ""
|
||||
echo "To test:"
|
||||
echo " 1. Plan mode: Ask the agent to plan something"
|
||||
echo " 2. Code review: Run /plannotator-review"
|
||||
if [ "$NO_GIT" = false ]; then
|
||||
echo " 2. Code review: Run /plannotator-review"
|
||||
fi
|
||||
echo ""
|
||||
echo "Launching OpenCode..."
|
||||
echo ""
|
||||
|
||||
Reference in New Issue
Block a user