refactor: slim git_repositories and config_profiles routers (Task 3.5)

- Extract git control operations to services/git/control.py with repo validation
- Extract git file operations to services/git/files.py with repo validation
- Extract repository lifecycle to services/git/repository.py (create, delete, list)
- Extract config profile helpers to services/config_profiles.py
  (cycle detection, duplicate checks, serialization, default profile management)
- Slim git_repositories.py from ~1050 to 276 lines
- Slim config_profiles.py from ~765 to 299 lines
- Both routers now contain only HTTP routing concerns

Quality gates: py_compile (pass), file size ≤300 (pass), no subprocess in routers (pass)
Refs: repo-restructure Task 3.5
This commit is contained in:
Developer
2026-06-02 21:28:15 +00:00
parent cccf4379d8
commit dd69bd69fc
18 changed files with 1040 additions and 1106 deletions
+6 -2
View File
@@ -32,7 +32,9 @@ const SessionItem = ({ session }: { session: Session }) => {
className={`${styles.navItem} ${styles.sessionItem}`}
title={`${displayName} (${session.status})`}
>
<span className={`${styles.sessionStatus} ${isRunning ? styles.running : ""}`} />
<span
className={`${styles.sessionStatus} ${isRunning ? styles.running : ""}`}
/>
<Icon name={session.tool_icon as IconName} size="sm" />
<span className={styles.sessionName}>{displayName}</span>
</a>
@@ -97,7 +99,9 @@ export const AppShell = () => {
key={item.to}
to={item.to}
className={({ isActive }) =>
isActive ? `${styles.navItem} ${styles.navItemActive}` : styles.navItem
isActive
? `${styles.navItem} ${styles.navItemActive}`
: styles.navItem
}
end={item.to === "/"}
>
+87 -87
View File
@@ -4,100 +4,100 @@ import { commitChanges } from "../api/git_repositories";
import styles from "./features/git/CommitPanel.module.css";
interface CommitPanelProps {
projectId: string;
repoId: string;
modified: string[];
added: string[];
deleted: string[];
untracked: string[];
onCommit: () => void;
projectId: string;
repoId: string;
modified: string[];
added: string[];
deleted: string[];
untracked: string[];
onCommit: () => void;
}
export const CommitPanel = ({
projectId,
repoId,
modified,
added,
deleted,
untracked,
onCommit,
projectId,
repoId,
modified,
added,
deleted,
untracked,
onCommit,
}: CommitPanelProps) => {
const [message, setMessage] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const allFiles = [...modified, ...added, ...deleted, ...untracked];
const hasChanges = allFiles.length > 0;
const allFiles = [...modified, ...added, ...deleted, ...untracked];
const hasChanges = allFiles.length > 0;
const handleCommit = async () => {
if (!message.trim()) {
setError("Please enter a commit message");
return;
}
setLoading(true);
setError(null);
try {
await commitChanges(projectId, repoId, message);
setMessage("");
onCommit();
} catch {
setError("Commit failed. Please try again.");
} finally {
setLoading(false);
}
};
const handleCommit = async () => {
if (!message.trim()) {
setError("Please enter a commit message");
return;
}
setLoading(true);
setError(null);
try {
await commitChanges(projectId, repoId, message);
setMessage("");
onCommit();
} catch {
setError("Commit failed. Please try again.");
} finally {
setLoading(false);
}
};
if (!hasChanges) return null;
if (!hasChanges) return null;
return (
<div className={styles.commitPanel}>
<h4>Changes</h4>
<div className={styles.fileList}>
{modified.map((file) => (
<div key={file} className={`${styles.fileItem} modified`}>
<span className={styles.fileStatus}>M</span>
<span>{file}</span>
</div>
))}
{added.map((file) => (
<div key={file} className={`${styles.fileItem} added`}>
<span className={styles.fileStatus}>A</span>
<span>{file}</span>
</div>
))}
{deleted.map((file) => (
<div key={file} className={`${styles.fileItem} deleted`}>
<span className={styles.fileStatus}>D</span>
<span>{file}</span>
</div>
))}
{untracked.map((file) => (
<div key={file} className={`${styles.fileItem} untracked`}>
<span className={styles.fileStatus}>?</span>
<span>{file}</span>
</div>
))}
</div>
return (
<div className={styles.commitPanel}>
<h4>Changes</h4>
<div className={styles.commitForm}>
<textarea
placeholder="Commit message"
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={2}
className={styles.commitMessageInput}
/>
{error && <div className={styles.commitError}>{error}</div>}
<button
onClick={handleCommit}
disabled={loading || !message.trim()}
className={styles.commitButton}
type="button"
>
{loading ? "Committing..." : "Commit"}
</button>
</div>
</div>
);
<div className={styles.fileList}>
{modified.map((file) => (
<div key={file} className={`${styles.fileItem} modified`}>
<span className={styles.fileStatus}>M</span>
<span>{file}</span>
</div>
))}
{added.map((file) => (
<div key={file} className={`${styles.fileItem} added`}>
<span className={styles.fileStatus}>A</span>
<span>{file}</span>
</div>
))}
{deleted.map((file) => (
<div key={file} className={`${styles.fileItem} deleted`}>
<span className={styles.fileStatus}>D</span>
<span>{file}</span>
</div>
))}
{untracked.map((file) => (
<div key={file} className={`${styles.fileItem} untracked`}>
<span className={styles.fileStatus}>?</span>
<span>{file}</span>
</div>
))}
</div>
<div className={styles.commitForm}>
<textarea
placeholder="Commit message"
value={message}
onChange={(e) => setMessage(e.target.value)}
rows={2}
className={styles.commitMessageInput}
/>
{error && <div className={styles.commitError}>{error}</div>}
<button
onClick={handleCommit}
disabled={loading || !message.trim()}
className={styles.commitButton}
type="button"
>
{loading ? "Committing..." : "Commit"}
</button>
</div>
</div>
);
};
@@ -1,76 +1,84 @@
.commitPanel {
padding: 1rem;
border-top: 1px solid var(--border);
background: var(--panel);
padding: 1rem;
border-top: 1px solid var(--border);
background: var(--panel);
}
.commitPanel h4 {
margin: 0 0 0.5rem 0;
font-size: 0.875rem;
font-weight: 600;
margin: 0 0 0.5rem 0;
font-size: 0.875rem;
font-weight: 600;
}
.fileList {
max-height: 150px;
overflow: auto;
margin-bottom: 0.75rem;
max-height: 150px;
overflow: auto;
margin-bottom: 0.75rem;
}
.fileItem {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0;
font-size: 0.8125rem;
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0;
font-size: 0.8125rem;
}
.fileStatus {
font-weight: bold;
font-size: 0.75rem;
width: 1rem;
text-align: center;
font-weight: bold;
font-size: 0.75rem;
width: 1rem;
text-align: center;
}
.fileItem.modified .fileStatus { color: #f59e0b; }
.fileItem.added .fileStatus { color: #10b981; }
.fileItem.deleted .fileStatus { color: #ef4444; }
.fileItem.untracked .fileStatus { color: #6b7280; }
.fileItem.modified .fileStatus {
color: #f59e0b;
}
.fileItem.added .fileStatus {
color: #10b981;
}
.fileItem.deleted .fileStatus {
color: #ef4444;
}
.fileItem.untracked .fileStatus {
color: #6b7280;
}
.commitForm {
display: flex;
flex-direction: column;
gap: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.commitMessageInput {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--ink);
font-family: inherit;
font-size: 0.875rem;
resize: vertical;
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg);
color: var(--ink);
font-family: inherit;
font-size: 0.875rem;
resize: vertical;
}
.commitButton {
padding: 0.5rem 1rem;
background: var(--primary);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
padding: 0.5rem 1rem;
background: var(--primary);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
}
.commitButton:disabled {
opacity: 0.5;
cursor: not-allowed;
opacity: 0.5;
cursor: not-allowed;
}
.commitError {
color: #ef4444;
font-size: 0.8125rem;
color: #ef4444;
font-size: 0.8125rem;
}
@@ -1,45 +1,45 @@
.fileViewer {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.fileViewerHeader {
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
background: var(--bg);
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
background: var(--bg);
}
.fileBreadcrumbs {
font-size: 0.875rem;
font-family: monospace;
font-size: 0.875rem;
font-family: monospace;
}
.breadcrumbSep {
color: var(--muted);
margin: 0 0.25rem;
color: var(--muted);
margin: 0 0.25rem;
}
.fileContent {
padding: 1rem;
overflow: auto;
max-height: calc(100vh - 200px);
padding: 1rem;
overflow: auto;
max-height: calc(100vh - 200px);
}
.fileContent pre {
margin: 0;
font-family: "IBM Plex Mono", monospace;
font-size: 0.875rem;
line-height: 1.5;
white-space: pre-wrap;
word-wrap: break-word;
margin: 0;
font-family: "IBM Plex Mono", monospace;
font-size: 0.875rem;
line-height: 1.5;
white-space: pre-wrap;
word-wrap: break-word;
}
.fileViewerEmpty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 300px;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 300px;
}
@@ -1,89 +1,89 @@
.settingsLayout {
display: flex;
gap: 2rem;
padding: 1.5rem 0;
display: flex;
gap: 2rem;
padding: 1.5rem 0;
}
.settingsSidebar {
width: 200px;
flex-shrink: 0;
width: 200px;
flex-shrink: 0;
}
.settingsNav {
display: flex;
flex-direction: column;
gap: 0.25rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.settingsNavLink {
padding: 0.625rem 1rem;
border-radius: 8px;
color: var(--muted);
text-decoration: none;
font-size: 0.95rem;
transition: all 0.2s;
padding: 0.625rem 1rem;
border-radius: 8px;
color: var(--muted);
text-decoration: none;
font-size: 0.95rem;
transition: all 0.2s;
}
.settingsNavLink:hover {
background: var(--bg);
color: var(--ink);
background: var(--bg);
color: var(--ink);
}
.settingsNavLinkActive {
background: var(--brand);
color: white;
font-weight: 500;
background: var(--brand);
color: white;
font-weight: 500;
}
.settingsContent {
flex: 1;
min-width: 0;
flex: 1;
min-width: 0;
}
.settingsPanel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 1.5rem;
background: var(--panel);
border: 1px solid var(--border);
border-radius: 14px;
padding: 1.5rem;
}
.settingsBreadcrumb {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
color: var(--muted);
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
color: var(--muted);
margin-bottom: 1rem;
}
.settingsBreadcrumb a {
color: var(--brand);
text-decoration: none;
color: var(--brand);
text-decoration: none;
}
.settingsBreadcrumb a:hover {
text-decoration: underline;
text-decoration: underline;
}
@media (max-width: 768px) {
.settingsLayout {
flex-direction: column;
gap: 1rem;
}
.settingsLayout {
flex-direction: column;
gap: 1rem;
}
.settingsSidebar {
width: 100%;
}
.settingsSidebar {
width: 100%;
}
.settingsNav {
flex-direction: row;
overflow-x: auto;
padding-bottom: 0.5rem;
}
.settingsNav {
flex-direction: row;
overflow-x: auto;
padding-bottom: 0.5rem;
}
.settingsNavLink {
white-space: nowrap;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
}
.settingsNavLink {
white-space: nowrap;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
}
}
+4 -1
View File
@@ -223,7 +223,10 @@ export const InstanceList = ({
/>
{instance.status}
{isTunnelUnhealthy(instance) && (
<span className={styles.errorBadge} title="Tunnel unreachable">
<span
className={styles.errorBadge}
title="Tunnel unreachable"
>
<Icon name="warning" size="sm" />
tunnel error
</span>
@@ -1,147 +1,147 @@
.shell {
min-height: 100vh;
display: flex;
flex-direction: column;
min-height: 100vh;
display: flex;
flex-direction: column;
}
.shellHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.85rem 1.25rem;
border-bottom: 1px solid var(--border);
background: color-mix(in srgb, var(--panel) 88%, transparent);
backdrop-filter: blur(7px);
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.85rem 1.25rem;
border-bottom: 1px solid var(--border);
background: color-mix(in srgb, var(--panel) 88%, transparent);
backdrop-filter: blur(7px);
}
.brand {
font-weight: 700;
letter-spacing: 0.02em;
font-weight: 700;
letter-spacing: 0.02em;
}
.headerActions {
display: flex;
align-items: center;
gap: 0.75rem;
display: flex;
align-items: center;
gap: 0.75rem;
}
.shellBody {
display: grid;
grid-template-columns: 230px 1fr;
min-height: calc(100vh - 57px);
display: grid;
grid-template-columns: 230px 1fr;
min-height: calc(100vh - 57px);
}
.shellNav {
border-right: 1px solid var(--border);
padding: 1rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
background: color-mix(in srgb, var(--panel) 65%, transparent);
border-right: 1px solid var(--border);
padding: 1rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
background: color-mix(in srgb, var(--panel) 65%, transparent);
}
.navItem {
padding: 0.65rem 0.75rem;
border-radius: 10px;
color: var(--muted);
padding: 0.65rem 0.75rem;
border-radius: 10px;
color: var(--muted);
}
.navItem:hover {
background: #ece7df;
color: var(--ink);
background: #ece7df;
color: var(--ink);
}
.navItemActive {
background: var(--brand);
color: #f7fff7;
background: var(--brand);
color: #f7fff7;
}
.navSectionTitle {
margin-top: 0.5rem;
padding: 0.25rem 0.75rem;
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
margin-top: 0.5rem;
padding: 0.25rem 0.75rem;
font-size: var(--font-size-xs);
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--muted);
}
.navDivider {
height: 1px;
background: var(--border);
margin: 0.5rem 0;
height: 1px;
background: var(--border);
margin: 0.5rem 0;
}
.navBadge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
background: var(--primary);
color: var(--primary-fg);
border-radius: 9px;
font-size: 11px;
font-weight: 600;
margin-left: auto;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
background: var(--primary);
color: var(--primary-fg);
border-radius: 9px;
font-size: 11px;
font-weight: 600;
margin-left: auto;
}
.sessionItem {
position: relative;
padding-left: var(--space-6);
position: relative;
padding-left: var(--space-6);
}
.sessionStatus {
position: absolute;
left: var(--space-2);
top: 50%;
transform: translateY(-50%);
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--muted);
position: absolute;
left: var(--space-2);
top: 50%;
transform: translateY(-50%);
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--muted);
}
.sessionStatus.running {
background: var(--success);
background: var(--success);
}
.sessionName {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 140px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 140px;
}
.shellContent {
padding: 1.25rem;
overflow-x: hidden;
padding: 1.25rem;
overflow-x: hidden;
}
@media (max-width: 767px) {
.shellBody {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
}
.shellBody {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
}
.shellNav {
flex-direction: row;
flex-wrap: wrap;
gap: 0.25rem;
padding: 0.5rem;
border-right: none;
border-bottom: 1px solid var(--border);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.shellNav {
flex-direction: row;
flex-wrap: wrap;
gap: 0.25rem;
padding: 0.5rem;
border-right: none;
border-bottom: 1px solid var(--border);
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.navItem {
padding: 0.5rem 0.75rem;
white-space: nowrap;
font-size: var(--font-size-sm);
}
.navItem {
padding: 0.5rem 0.75rem;
white-space: nowrap;
font-size: var(--font-size-sm);
}
.shellContent {
padding: var(--space-4);
}
.shellContent {
padding: var(--space-4);
}
}
+32 -30
View File
@@ -3,42 +3,44 @@ import { Link, useLocation } from "react-router-dom";
import styles from "./features/settings/SettingsTabLayout.module.css";
interface Tab {
id: string;
label: string;
path: string;
id: string;
label: string;
path: string;
}
interface SettingsTabLayoutProps {
tabs: Tab[];
children: React.ReactNode;
basePath: string;
tabs: Tab[];
children: React.ReactNode;
basePath: string;
}
export const SettingsTabLayout: React.FC<SettingsTabLayoutProps> = ({
tabs,
children,
basePath,
tabs,
children,
basePath,
}) => {
const location = useLocation();
const location = useLocation();
return (
<div className={styles.settingsLayout}>
<aside className={styles.settingsSidebar}>
<nav className={styles.settingsNav}>
{tabs.map((tab) => (
<Link
key={tab.id}
to={`${basePath}/${tab.path}`}
className={`${styles.settingsNavLink} ${
location.pathname.includes(tab.path) ? styles.settingsNavLinkActive : ""
}`}
>
{tab.label}
</Link>
))}
</nav>
</aside>
<main className={styles.settingsContent}>{children}</main>
</div>
);
return (
<div className={styles.settingsLayout}>
<aside className={styles.settingsSidebar}>
<nav className={styles.settingsNav}>
{tabs.map((tab) => (
<Link
key={tab.id}
to={`${basePath}/${tab.path}`}
className={`${styles.settingsNavLink} ${
location.pathname.includes(tab.path)
? styles.settingsNavLinkActive
: ""
}`}
>
{tab.label}
</Link>
))}
</nav>
</aside>
<main className={styles.settingsContent}>{children}</main>
</div>
);
};