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
2 changes: 1 addition & 1 deletion docs/GIT_WORKFLOW.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ Edit → Validate → Commit → Push → (optional) Deploy → (optional) Watch

## Implemented provider foundation (PR #4)

The low-level `GitProvider` supports `inspectBranch`, `commit`, `push`, and `getCommit` for all three smart-HTTP adapters. `GitWorkflowService` provides the application/API layer below. The final Changes UI is separate work.
The low-level `GitProvider` supports `inspectBranch`, `commit`, `push`, and `getCommit` for all three smart-HTTP adapters. `GitWorkflowService` provides the application/API layer. The Changes tab commits selected drafts and can push or retry a retained commit.

## Application API contract (v0.5.0)

Expand Down
9 changes: 5 additions & 4 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ saved tokens, the audit log, your Git author identity, password and sessions.

stack-manager works with GitHub, Gitea and Forgejo over HTTPS.

1. Create an access token on your Git server for the repository. Read access is enough for now; write access will be
needed once committing from stack-manager is available.
1. Create an access token on your Git server for the repository. Read access is enough to browse and draft; write
access is needed to push commits from the **Changes** tab.
2. In stack-manager, choose **Connect repository** and fill in:
- **Git provider** and the **HTTPS clone URL**. Never put the token in the URL.
- **Access token** (or a saved credential), plus a **username** only if your server requires one with the token.
Expand Down Expand Up @@ -80,7 +80,7 @@ Each stack has these tabs:
| **Editor** | Browse files in the explorer, open them in tabs and edit with syntax highlighting. Compose files are validated as you type and issues appear in the problems panel. **Save draft** (Ctrl+S / ⌘S) keeps your edit on the server. |
| **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. Discard the drafts you don't want. |
| **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. |
| **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
Expand All @@ -91,7 +91,8 @@ can't be opened or edited.
Drafts are stored in stack-manager's database and never leave the server. If the file changes in Git after you start a
draft, the editor warns you, so you don't overwrite someone else's change unknowingly.

Committing drafts, safe push and Git history ship in v0.5.0; see the [roadmap](plans/MVP_PLAN.md).
Set your **Git identity** under Settings → Account before the first commit. After a successful push, drafts stay
on the server until you discard them — fetch the repository if the remote moved ahead.

## 5. Files outside stacks

Expand Down
15 changes: 15 additions & 0 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,31 @@ export default defineConfig({
{
name: 'desktop',
testMatch: /\.spec\.ts/,
// Draft-mutating Git workflow tests share the sample stack; run them after source/shell.
testIgnore: /changes-commit\.spec\.ts/,
dependencies: ['onboarding'],
use: { ...devices['Desktop Chrome'], viewport: { width: 1280, height: 860 } },
},
{
name: 'mobile',
testMatch: /\.spec\.ts/,
testIgnore: /changes-commit\.spec\.ts/,
dependencies: ['onboarding'],
// Pixel 7 is Chromium-based, so no WebKit download is needed.
use: { ...devices['Pixel 7'] },
},
{
name: 'git-workflow',
testMatch: /changes-commit\.spec\.ts/,
dependencies: ['desktop', 'mobile'],
use: { ...devices['Desktop Chrome'], viewport: { width: 1280, height: 860 } },
},
{
name: 'git-workflow-mobile',
testMatch: /changes-commit\.spec\.ts/,
dependencies: ['git-workflow'],
use: { ...devices['Pixel 7'] },
},
],
webServer: process.env.E2E_BASE_URL
? undefined
Expand Down
8 changes: 8 additions & 0 deletions src/app/styles/components.css
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,9 @@ button.list-row {
input[type='text'],
input[type='password'],
input[type='url'],
input[type='email'],
input[type='search'],
textarea,
select {
font: inherit;
width: 100%;
Expand All @@ -410,6 +412,12 @@ select {
border-radius: var(--radius-sm);
}

textarea {
min-height: 5.5rem;
resize: vertical;
line-height: 1.45;
}

input[aria-invalid='true'] {
border-color: var(--danger);
}
Expand Down
36 changes: 36 additions & 0 deletions src/app/styles/source.css
Original file line number Diff line number Diff line change
Expand Up @@ -740,6 +740,42 @@
border-bottom: 1px solid var(--border);
}

.changes-summary {
font-size: 0.95rem;
}

.focus-target:focus {
outline: none;
}

.commit-panel .commit-form {
padding: var(--space-3) var(--space-4) var(--space-4);
}

.commit-actions {
padding-block: var(--space-2) 0;
}

.change-select {
flex: 1;
min-width: 0;
}

.problem-list {
margin: 0.35rem 0 0;
padding-left: 1.2rem;
}

.alert .actions {
margin-top: var(--space-3);
}

@media (max-width: 900px) {
.commit-actions .btn {
flex: 1 1 100%;
}
}

/* ---------- Narrow screens ---------- */
@media (max-width: 900px) {
.ide {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { Metadata } from 'next';
import { getContainer } from '@/server/container';
import { ChangesView } from '@/ui/source/changes-view';
import { NotFetched, repositorySourceApi } from '../../../../../_lib/repository-source';
import { requireSession } from '@/app/_lib/session';
import { NotFetched, repositorySourceApi } from '@/app/_lib/repository-source';

export const metadata: Metadata = { title: 'Changes' };

Expand All @@ -12,18 +13,26 @@ export default async function RepositoryChangesPage({
params: Promise<{ workspaceId: string; repositoryId: string }>;
}) {
const { workspaceId, repositoryId } = await params;
const { repositories, source } = getContainer();
const session = await requireSession();
const { repositories, source, auth } = getContainer();
const repo = await repositories.get(workspaceId, repositoryId);
if (!repo.headSha) return <NotFetched />;
const changes = await source.changes(workspaceId, repositoryId, '');
const [changes, identity] = await Promise.all([
source.changes(workspaceId, repositoryId, ''),
auth.getGitIdentity(session.user.id),
]);
return (
<div className="wide-page">
<ChangesView
key={repositoryId}
workspaceId={workspaceId}
repositoryId={repositoryId}
apiBase={repositorySourceApi(workspaceId, repositoryId)}
rootPath=""
changes={changes}
editorHref={`/w/${workspaceId}/repositories/${repositoryId}/files`}
branch={repo.defaultBranch}
gitIdentity={identity}
/>
</div>
);
Expand Down
14 changes: 12 additions & 2 deletions src/app/w/[workspaceId]/stacks/[stackId]/changes/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Metadata } from 'next';
import { getContainer } from '@/server/container';
import { ChangesView } from '@/ui/source/changes-view';
import { requireSession } from '@/app/_lib/session';

export const metadata: Metadata = { title: 'Changes' };

Expand All @@ -10,16 +11,25 @@ export default async function StackChangesPage({
params: Promise<{ workspaceId: string; stackId: string }>;
}) {
const { workspaceId, stackId } = await params;
const { stacks, source } = getContainer();
const session = await requireSession();
const { stacks, source, auth } = getContainer();
const stack = await stacks.get(workspaceId, stackId);
const changes = await source.changes(workspaceId, stack.repository.id, stack.rootPath);
const [changes, identity] = await Promise.all([
source.changes(workspaceId, stack.repository.id, stack.rootPath),
auth.getGitIdentity(session.user.id),
]);
return (
<ChangesView
key={stack.repository.id}
workspaceId={workspaceId}
repositoryId={stack.repository.id}
apiBase={`/api/workspaces/${workspaceId}/stacks/${stackId}`}
rootPath={stack.rootPath}
changes={changes}
editorHref={`/w/${workspaceId}/stacks/${stackId}`}
branch={stack.repository.defaultBranch}
gitIdentity={identity}
composePath={stack.composePath}
/>
);
}
59 changes: 46 additions & 13 deletions src/ui/api.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
'use client';

import type { GitWorkflowResult } from '@/shared/git-workflow';

export class ApiError extends Error {
constructor(
readonly status: number,
Expand All @@ -11,25 +13,56 @@ export class ApiError extends Error {
}
}

/** Same-origin JSON fetch. The session cookie is HTTP-only and sent automatically. */
export async function api<T>(path: string, init: { method?: string; body?: unknown } = {}): Promise<T> {
async function requestJson(path: string, init: { method?: string; body?: unknown } = {}) {
const res = await fetch(path, {
method: init.method ?? 'GET',
headers: init.body === undefined ? undefined : { 'Content-Type': 'application/json' },
body: init.body === undefined ? undefined : JSON.stringify(init.body),
credentials: 'same-origin',
cache: 'no-store',
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
const err = (data as { error?: { code?: string; message?: string; fields?: Record<string, string> } })
.error;
throw new ApiError(
res.status,
err?.code ?? 'error',
err?.message ?? `Request failed (${res.status}).`,
err?.fields,
);
}
const data: unknown = await res.json().catch(() => ({}));
return { res, data };
}

function throwApiError(status: number, data: unknown): never {
const err = (data as { error?: { code?: string; message?: string; fields?: Record<string, string> } })
.error;
throw new ApiError(
status,
err?.code ?? 'error',
err?.message ?? `Request failed (${status}).`,
err?.fields,
);
}

/** Same-origin JSON fetch. The session cookie is HTTP-only and sent automatically. */
export async function api<T>(path: string, init: { method?: string; body?: unknown } = {}): Promise<T> {
const { res, data } = await requestJson(path, init);
if (!res.ok) throwApiError(res.status, data);
return data as T;
}

function isGitWorkflowResult(data: unknown): data is GitWorkflowResult {
return (
!!data &&
typeof data === 'object' &&
'status' in data &&
'draftsPreserved' in data &&
(data as GitWorkflowResult).draftsPreserved === true
);
}

/**
* Git workflow routes return a structured `GitWorkflowResult` for both success and
* expected conflict/validation outcomes (409/422/502). Malformed requests still use the error envelope.
*/
export async function gitWorkflow(
path: string,
init: { method?: string; body?: unknown } = {},
): Promise<GitWorkflowResult> {
const { res, data } = await requestJson(path, init);
if (isGitWorkflowResult(data)) return data;
if (!res.ok) throwApiError(res.status, data);
throw new ApiError(res.status, 'error', 'Unexpected Git workflow response.');
}
Loading
Loading