diff --git a/docs/UX.md b/docs/UX.md
index a2f9545..130e46e 100644
--- a/docs/UX.md
+++ b/docs/UX.md
@@ -27,15 +27,16 @@ Signed-in operators work inside a workspace. Primary sidebar navigation (desktop
**Header:** stack name + optional runtime strip (health, container count, image, last refresh)
-**Tabs:** Compose | Files | Docs | Environment | Secrets | Changes | Deployments
+**Tabs:** Editor | Docs | Environment | Changes | History | Settings (Secrets and Deployments arrive later)
No primary nav items for Containers / Images / Networks / Volumes.
Repository pages have **Overview**, **Files**, **Docs** and **Changes** tabs: the same editor, docs and review
views scoped to the whole repository, for files outside any stack (root README, `docs/`, shared `.env.example`).
-Implemented in PR #3: **Editor** (explorer, tabs, problems, status bar), **Docs**, **Environment**, **Changes** and
-**Settings** tabs. Secrets and Deployments arrive with PR #6 and PR #5.
+Implemented: **Editor**, **Docs**, **Environment**, **Changes**, **History** (stack-scoped commits at the
+fetched snapshot) and **Settings**. Draft counts appear on the Changes tab and stack header when work is outstanding.
+Secrets and Deployments arrive with PR #6 and PR #5.
## Desktop
diff --git a/docs/getting-started.md b/docs/getting-started.md
index 9897586..35093a1 100644
--- a/docs/getting-started.md
+++ b/docs/getting-started.md
@@ -81,6 +81,7 @@ Each stack has these tabs:
| **Docs** | Read the stack's Markdown files rendered (README first), follow links between them and edit them as drafts. |
| **Environment** | Variables the Compose file references, whether `.env.example` documents them, env files it expects, and values that look like hard-coded secrets. Values are never shown. |
| **Changes** | Every draft as a line-by-line diff against the fetched commit. Select files, commit with your Account Git identity, and push when ready. Discard drafts you don't want. |
+| **History** | Commits that touched this stack at the last fetched snapshot. Open a commit to inspect the scoped file diffs. |
| **Settings** | Rename the stack, change its Compose file or remove it (files and drafts are kept). |
Files that look like secrets (`.env`, private keys, certificates, anything in a `secrets/` folder) are listed but
diff --git a/docs/index.md b/docs/index.md
index e905147..95630d2 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -35,7 +35,8 @@ stack-manager looks after the Git source they are deployed from.
stack-manager is in early development (0.x). Available today: sign-in, workspaces, encrypted credentials, repository
connections, stacks, dashboard and settings, the editor with drafts, docs, the environment inventory and change review.
-Coming in v0.5.0: committing drafts, safe push and Git history. After that on the [roadmap](plans/MVP_PLAN.md):
+Stack-scoped Git history is on each stack's History tab. Draft commit and safe push are on Changes. Next on the
+[roadmap](plans/MVP_PLAN.md):
deploying only the stacks a commit changed (through Portainer and others), then optional read-only runtime feedback.
## Documentation
diff --git a/src/app/styles/source.css b/src/app/styles/source.css
index 69998d9..eba075a 100644
--- a/src/app/styles/source.css
+++ b/src/app/styles/source.css
@@ -1197,6 +1197,99 @@ select.compact-select {
font-size: var(--text-xs);
font-weight: 600;
white-space: nowrap;
+ text-decoration: none;
+}
+
+.page-title-row .draft-badge {
+ margin-left: 0;
+}
+
+/* ---------- Stack history ---------- */
+.history-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: grid;
+ gap: var(--space-3);
+}
+
+.history-item {
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ background: var(--surface);
+ overflow: hidden;
+}
+
+.history-row {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-3);
+ width: 100%;
+ padding: var(--space-3) var(--space-4);
+ border: 0;
+ background: transparent;
+ color: inherit;
+ text-align: left;
+ cursor: pointer;
+}
+
+.history-row:hover,
+.history-row:focus-visible {
+ background: var(--surface-2);
+}
+
+.history-row-main {
+ display: grid;
+ gap: var(--space-1);
+ min-width: 0;
+ flex: 1;
+}
+
+.history-subject {
+ font-weight: 600;
+ overflow-wrap: anywhere;
+}
+
+.history-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0 0.25rem;
+}
+
+.history-files,
+.history-files-empty {
+ margin: 0;
+ padding: 0 var(--space-4) var(--space-3);
+ padding-left: calc(var(--space-4) + 1.5rem);
+ list-style: none;
+ display: grid;
+ gap: var(--space-1);
+ font-size: var(--text-sm);
+}
+
+.history-files li {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--space-2);
+ min-width: 0;
+}
+
+.history-files .mono {
+ overflow-wrap: anywhere;
+}
+
+.history-detail {
+ border-top: 1px solid var(--border);
+}
+
+.history-detail-title {
+ margin: 0;
+ font-size: var(--text-md);
+}
+
+.history-file-diff + .history-file-diff {
+ border-top: 1px solid var(--border);
}
/* Flat lists (grouped by repository only, or not at all) have no folder labels above their first row. */
diff --git a/src/app/w/[workspaceId]/stacks/[stackId]/history/page.tsx b/src/app/w/[workspaceId]/stacks/[stackId]/history/page.tsx
new file mode 100644
index 0000000..91c7677
--- /dev/null
+++ b/src/app/w/[workspaceId]/stacks/[stackId]/history/page.tsx
@@ -0,0 +1,19 @@
+import type { Metadata } from 'next';
+import { getContainer } from '@/server/container';
+import { HistoryView } from '@/ui/source/history-view';
+import { orNotFound } from '../../../../../_lib/session';
+
+export const metadata: Metadata = { title: 'History' };
+
+export default async function StackHistoryPage({
+ params,
+}: {
+ params: Promise<{ workspaceId: string; stackId: string }>;
+}) {
+ const { workspaceId, stackId } = await params;
+ const { stacks } = getContainer();
+ const stack = await orNotFound(stacks.get(workspaceId, stackId));
+ return (
+
+ );
+}
diff --git a/src/app/w/[workspaceId]/stacks/[stackId]/layout.tsx b/src/app/w/[workspaceId]/stacks/[stackId]/layout.tsx
index 480123e..7a67ea4 100644
--- a/src/app/w/[workspaceId]/stacks/[stackId]/layout.tsx
+++ b/src/app/w/[workspaceId]/stacks/[stackId]/layout.tsx
@@ -1,4 +1,4 @@
-import { Folder, FolderGit2, GitBranch, GitCommitHorizontal, Hourglass } from 'lucide-react';
+import { Folder, FolderGit2, GitBranch, GitCommitHorizontal, Hourglass, PencilLine } from 'lucide-react';
import Link from 'next/link';
import { getContainer } from '@/server/container';
import { EmptyState } from '@/ui/primitives/empty-state';
@@ -24,6 +24,7 @@ export default async function StackLayout({
stacks.listForRepository(workspaceId, stack.repository.id),
]);
const base = `/w/${workspaceId}/stacks/${stackId}`;
+ const draftLabel = `${drafts} draft${drafts === 1 ? '' : 's'}`;
return (
@@ -37,6 +38,18 @@ export default async function StackLayout({
/>
}
title={stack.name}
+ status={
+ drafts > 0 ? (
+
+
+ {draftLabel}
+
+ ) : null
+ }
actions={
No textual changes.;
- return (
-
- {patch.hunks.map((hunk, h) => {
- let oldLine = hunk.oldStart;
- let newLine = hunk.newStart;
- return (
-
-
-
- @@ -{hunk.oldStart},{hunk.oldLines} +{hunk.newStart},{hunk.newLines} @@
-
-
- {hunk.lines.map((line, i) => {
- const sign = line[0];
- if (sign === '\\') return null;
- const kind = sign === '+' ? 'add' : sign === '-' ? 'remove' : 'same';
- const oldNo = kind === 'add' ? '' : oldLine++;
- const newNo = kind === 'remove' ? '' : newLine++;
- return (
-
-
- {oldNo}
-
-
- {newNo}
-
-
- {kind === 'same' ? ' ' : sign}
-
- {line.slice(1)}
-
- );
- })}
-
- );
- })}
-
- );
+ return ;
}
function problemLabel(problem: GitWorkflowProblem) {
diff --git a/src/ui/source/history-view.tsx b/src/ui/source/history-view.tsx
new file mode 100644
index 0000000..5297b1c
--- /dev/null
+++ b/src/ui/source/history-view.tsx
@@ -0,0 +1,301 @@
+'use client';
+
+import { GitCommitHorizontal, History } from 'lucide-react';
+import { useEffect, useId, useState } from 'react';
+import type { HistoryCommit, HistoryDetail, HistoryPage } from '@/shared/git-history';
+import { relativeTo } from '@/shared/source/paths';
+import { api, ApiError } from '../api';
+import { LocalTime } from '../local-time';
+import { Alert } from '../primitives/alert';
+import { Button } from '../primitives/button';
+import { EmptyState } from '../primitives/empty-state';
+import { UnifiedDiff } from './unified-diff';
+
+const FILE_STATUS: Record = {
+ added: 'Added',
+ modified: 'Modified',
+ deleted: 'Deleted',
+ type_changed: 'Type changed',
+};
+
+const LOCKED_REASON: Record, string> = {
+ secret: 'Secret file — contents are not shown.',
+ symlink: 'Symlink — contents are not shown.',
+ submodule: 'Submodule — contents are not shown.',
+ binary: 'Binary file — contents are not shown.',
+ too_large: 'File is too large to show a diff.',
+ diff_limit: 'Diff exceeded size limits.',
+ unsupported_path: 'This path cannot be shown in the editor.',
+};
+
+const PREVIEW_FILES = 8;
+
+function fileLabel(rootPath: string, path: string) {
+ return relativeTo(rootPath, path) || path;
+}
+
+async function fetchPage(apiBase: string, cursor: string | null): Promise {
+ const q = new URLSearchParams({ limit: '20' });
+ if (cursor) q.set('cursor', cursor);
+ return api(`${apiBase}/history?${q}`);
+}
+
+function FileList({
+ rootPath,
+ files,
+ truncated,
+}: {
+ rootPath: string;
+ files: HistoryCommit['files'];
+ truncated: boolean;
+}) {
+ const [expanded, setExpanded] = useState(false);
+ const visible = expanded ? files : files.slice(0, PREVIEW_FILES);
+ const hidden = files.length - visible.length;
+ return (
+
+ {visible.map((f) => (
+ -
+ {fileLabel(rootPath, f.path)}{' '}
+ {FILE_STATUS[f.status]}
+
+ ))}
+ {hidden > 0 || truncated ? (
+ -
+ {hidden > 0 ? (
+
+ ) : null}
+ {truncated ? File list truncated by the server. : null}
+
+ ) : null}
+
+ );
+}
+
+function CommitDetail({
+ apiBase,
+ rootPath,
+ sha,
+ onClose,
+}: {
+ apiBase: string;
+ rootPath: string;
+ sha: string;
+ onClose: () => void;
+}) {
+ const [detail, setDetail] = useState(null);
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ let cancelled = false;
+ void api(`${apiBase}/history/${sha}`)
+ .then((d) => {
+ if (cancelled) return;
+ setDetail(d);
+ setLoading(false);
+ })
+ .catch((err) => {
+ if (cancelled) return;
+ setError(err instanceof ApiError ? err.message : 'Could not load this commit.');
+ setLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [apiBase, sha]);
+
+ return (
+
+
+
+
Commit detail
+
{sha}
+
+
+
+ {loading ? Loading changes…
: null}
+ {error ? (
+
+ {error}
+
+ ) : null}
+ {detail
+ ? detail.commit.files.map((f) => {
+ const rel = fileLabel(rootPath, f.path);
+ return (
+
+
+
+ {rel} {FILE_STATUS[f.status]}
+
+
+ {f.locked ? (
+
{LOCKED_REASON[f.locked]}
+ ) : f.hunks === null ? (
+
Diff unavailable.
+ ) : (
+
+ )}
+
+ );
+ })
+ : null}
+ {detail && detail.commit.files.length === 0 ? (
+ No files changed in this stack scope.
+ ) : null}
+ {detail?.commit.filesTruncated ? (
+ File list truncated by the server.
+ ) : null}
+
+ );
+}
+
+/** Stack-scoped Git history at the last fetched snapshot, with optional commit detail. */
+export function HistoryView({ apiBase, rootPath }: { apiBase: string; rootPath: string }) {
+ const listId = useId();
+ const [commits, setCommits] = useState([]);
+ const [nextCursor, setNextCursor] = useState(null);
+ const [limitReached, setLimitReached] = useState(false);
+ const [loading, setLoading] = useState(true);
+ const [loadingMore, setLoadingMore] = useState(false);
+ const [error, setError] = useState(null);
+ const [selected, setSelected] = useState(null);
+ const [reloadToken, setReloadToken] = useState(0);
+
+ useEffect(() => {
+ let cancelled = false;
+ void fetchPage(apiBase, null)
+ .then((page) => {
+ if (cancelled) return;
+ setCommits(page.commits);
+ setNextCursor(page.nextCursor);
+ setLimitReached(page.historyLimitReached);
+ setError(null);
+ setLoading(false);
+ })
+ .catch((err) => {
+ if (cancelled) return;
+ setError(err instanceof ApiError ? err.message : 'Could not load history.');
+ setCommits([]);
+ setLoading(false);
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [apiBase, reloadToken]);
+
+ const loadMore = async () => {
+ if (!nextCursor) return;
+ setLoadingMore(true);
+ setError(null);
+ try {
+ const page = await fetchPage(apiBase, nextCursor);
+ setCommits((prev) => [...prev, ...page.commits]);
+ setNextCursor(page.nextCursor);
+ setLimitReached(page.historyLimitReached);
+ } catch (err) {
+ setError(err instanceof ApiError ? err.message : 'Could not load history.');
+ } finally {
+ setLoadingMore(false);
+ }
+ };
+
+ if (loading) {
+ return (
+
+ Loading history…
+
+ );
+ }
+
+ if (error && commits.length === 0) {
+ return (
+
+
+ {error}
+
+
+
+ );
+ }
+
+ if (commits.length === 0) {
+ return (
+
+ No commits touching this stack were found.
+
+ );
+ }
+
+ return (
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ {nextCursor ? (
+
+ ) : null}
+ {limitReached ?
History pagination limit reached for this fetch.
: null}
+
+ );
+}
diff --git a/src/ui/source/stack-tabs.tsx b/src/ui/source/stack-tabs.tsx
index 4e056ec..35bb22a 100644
--- a/src/ui/source/stack-tabs.tsx
+++ b/src/ui/source/stack-tabs.tsx
@@ -1,19 +1,62 @@
'use client';
-import { BookOpen, Code2, GitCompareArrows, Settings2, Variable } from 'lucide-react';
+import { BookOpen, Code2, GitCompareArrows, History, Settings2, Variable } from 'lucide-react';
import Link from 'next/link';
-import { usePathname } from 'next/navigation';
+import { usePathname, useRouter } from 'next/navigation';
+import { useEffect } from 'react';
/** Stack page sections (docs/UX.md). Deployments, runtime and secrets join in later phases. */
export function StackTabs({ base, drafts }: { base: string; drafts: number }) {
const pathname = usePathname();
+ const router = useRouter();
const items = [
{ href: base, label: 'Editor', Icon: Code2, exact: true },
{ href: `${base}/docs`, label: 'Docs', Icon: BookOpen },
{ href: `${base}/environment`, label: 'Environment', Icon: Variable },
{ href: `${base}/changes`, label: 'Changes', Icon: GitCompareArrows, count: drafts },
+ { href: `${base}/history`, label: 'History', Icon: History },
{ href: `${base}/settings`, label: 'Settings', Icon: Settings2 },
];
+
+ // ponytail: chord only; full shortcut sheet / command palette stays on the UI plan.
+ useEffect(() => {
+ let pendingG = false;
+ let timer: ReturnType | undefined;
+ const onKey = (e: KeyboardEvent) => {
+ const target = e.target as HTMLElement | null;
+ if (
+ target &&
+ (target.isContentEditable ||
+ target.tagName === 'INPUT' ||
+ target.tagName === 'TEXTAREA' ||
+ target.tagName === 'SELECT')
+ ) {
+ pendingG = false;
+ return;
+ }
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
+ const key = e.key.toLowerCase();
+ if (pendingG && key === 'c') {
+ e.preventDefault();
+ pendingG = false;
+ router.push(`${base}/changes`);
+ return;
+ }
+ pendingG = key === 'g';
+ clearTimeout(timer);
+ if (pendingG) {
+ timer = setTimeout(() => {
+ pendingG = false;
+ }, 1000);
+ }
+ };
+ window.addEventListener('keydown', onKey);
+ return () => {
+ window.removeEventListener('keydown', onKey);
+ clearTimeout(timer);
+ };
+ }, [base, router]);
+
return (