Compare commits

..

8 Commits

Author SHA1 Message Date
alex 391dd5ee80 docs: refresh README 2026-07-27 15:24:01 +02:00
alex d8d44f0c6a merge: suppress competing terminal context menus 2026-07-22 14:56:04 +02:00
alex bf561186fb fix(terminal): suppress competing context menus
Stop contextmenu propagation after recognizing selected xterm output so the native browser overlay cannot block Copy.
2026-07-22 14:56:03 +02:00
alex 0afce741eb merge: prioritize terminal selected copy menu 2026-07-22 14:25:21 +02:00
alex 7603b739cb fix(terminal): prioritize selected copy menu
Handle selected-output context menus during capture so xterm and the browser cannot open a competing menu.
2026-07-22 14:25:20 +02:00
alex ff8efdd887 merge: add terminal right click copy action 2026-07-22 14:15:47 +02:00
alex b289edf5a9 fix(terminal): add right click copy action
Replace the conflicting keyboard copy shortcut with an xterm-aware Copy action shown for selected output.
2026-07-22 14:15:46 +02:00
alex 75b67f2a6f merge: use ctrl shift c for terminal output copy 2026-07-22 13:01:32 +02:00
4 changed files with 141 additions and 202 deletions
+84 -146
View File
@@ -1,150 +1,61 @@
# Headquarter # Headquarter
A self-hosted platform for managing projects, git repositories, and development tools with OAuth2 authentication. Headquarter is a self-hosted workspace for managing projects, Git repositories, development-tool instances, SSH keys, and user preferences. It has a FastAPI API, a React frontend, PostgreSQL and Redis, and Authentik OAuth2/session authentication. Built-in tool definitions include code-server and Jupyter.
## Overview ## Prerequisites
Headquarter provides a centralized workspace for development teams to: - Docker and Docker Compose for the provided local and production Compose stacks.
- Manage projects and their associated git repositories - Git for repository workflows.
- Browse repository files and view git history - Python 3.11 or newer for manual API development.
- Spawn development tools (VS Code Server, Jupyter Notebook, etc.) - Node.js and npm for manual frontend development.
- Manage SSH keys and user preferences - An Authentik configuration for the authenticated deployment.
## Features Production additionally requires an existing Traefik network and host Docker access for tool-instance management.
### Project Management ## Local Compose setup
- Create and manage projects
- View all projects in a dashboard
- Click any project to open its workspace
### Git Repository Management 1. Create a local environment file from the template and replace placeholder credentials before using a shared or production-like environment:
- Initialize bare repositories
- Clone repositories (including mirror clones)
- Smart URL parsing (converts browser URLs to git URLs)
- View repository history and commit details
### Repository Workspace
- Browse files and directories
- View file contents with syntax highlighting
- Switch between branches
- Quick file editing with automatic commits
### Git History Visualization
- View commit history with branch graph
- See commit details, statistics, and diffs
- Filter by branch
### Authentication
- OAuth2 via Authentik
- Session-based authentication
- User profile management
### Tool Management
- Built-in tool types (code-server, jupyter-notebook)
- Create custom tool types with Docker Compose templates
- Template validation
### User Settings
- Theme selection (system/light/dark)
- Git identity configuration
- Default editor preference
### SSH Key Management
- Generate Ed25519 key pairs
- Copy public keys to clipboard
- Delete keys
## Quick Start
### Prerequisites
- Docker and Docker Compose
- Git
### Local Development
1. **Clone the repository:**
```bash
git clone <repository-url>
cd headquarter
```
2. **Set up environment:**
```bash ```bash
cp .env.example .env cp .env.example .env
# Edit .env with your settings
``` ```
3. **Start services:** 2. Start the local stack:
```bash ```bash
docker compose up -d docker compose up -d
``` ```
4. **Access the application:** The local Compose stack starts PostgreSQL, Redis, the API, and the web frontend. It publishes the web frontend at <http://localhost:3000>, the API at <http://localhost:8000>, and API documentation at <http://localhost:8000/docs>. PostgreSQL and Redis are also published on ports 5432 and 6379 respectively.
- Frontend: http://localhost:5173
- API: http://localhost:8000
- API Docs: http://localhost:8000/docs
### Production Deployment > **Authentication limitation:** this command does not configure the `AUTHENTIK_*`, `API_DOMAIN`, or `WEB_DOMAIN` values required for a verified authenticated flow. Treat authenticated local use as unsupported until those values are supplied through a documented local configuration.
See [Deployment Guide](docs/deployment/) for production setup with Traefik and Authentik. Useful operational commands:
## Tech Stack ```bash
make up # Start services
### Backend make down # Stop services
- **FastAPI** - Python web framework make logs # Follow Compose logs
- **SQLAlchemy** - ORM with async PostgreSQL support make migrate # Apply database migrations in the API container
- **Pydantic** - Data validation make health # Show Compose service status
- **Alembic** - Database migrations
- **python-jose** - JWT handling
### Frontend
- **React** - UI library
- **TypeScript** - Type safety
- **Vite** - Build tool
- **React Router** - Client-side routing
### Infrastructure
- **Docker** - Containerization
- **PostgreSQL** - Database
- **Traefik** - Reverse proxy (production)
- **Authentik** - Identity provider
## Documentation
- [User Guide](docs/features/) - Feature documentation
- [API Reference](docs/api/) - API endpoints
- [Architecture](docs/architecture/) - System design
- [Deployment](docs/deployment/) - Setup guides
- [Development](docs/development/) - Contributing
## Project Structure
```
.
├── apps/
│ ├── api/ # FastAPI backend
│ │ ├── src/
│ │ │ ├── api/ # API routes
│ │ │ ├── auth/ # Authentication
│ │ │ ├── models/ # Database models
│ │ │ └── utils/ # Utilities
│ │ ├── tests/ # Test suite
│ │ └── Dockerfile
│ └── web/ # React frontend
│ ├── src/
│ │ ├── api/ # API clients
│ │ ├── components/# UI components
│ │ └── pages/ # Page components
│ └── Dockerfile
├── docs/ # Documentation
├── docker-compose.yml # Development setup
├── docker-compose.traefik.yml # Production setup
└── Makefile # Common commands
``` ```
## Development ## Production deployment
The production Compose file is [`docker-compose.traefik.yml`](docker-compose.traefik.yml). It expects an existing external Traefik network (named `traefik` by default), configured domains, an Authentik client secret, and host paths for repositories, working copies, and tool instances.
After preparing `.env` with production values, deploy with:
```bash
docker compose -f docker-compose.traefik.yml up -d
```
The production API container mounts `/var/run/docker.sock` so it can manage tool instances. Treat this as privileged host access and restrict it appropriately. The deployment guide and supporting material are under [`docs/deployment/`](docs/deployment/).
## Manual development
### API
### Backend Development
```bash ```bash
cd apps/api cd apps/api
python -m venv .venv python -m venv .venv
@@ -153,43 +64,70 @@ pip install -e ".[dev]"
uvicorn src.main:app --reload uvicorn src.main:app --reload
``` ```
### Frontend Development ### Web
```bash ```bash
cd apps/web cd apps/web
npm install npm install
npm run dev npm run dev
``` ```
### Running Tests ## Tests and quality checks
The Make targets run commands in the Compose services:
```bash ```bash
# Backend tests
make test make test
make test-unit
# Frontend tests make test-integration
make test-web make test-system
make test-e2e
# All quality gates
make lint make lint
make typecheck make typecheck
make build
``` ```
The frontend test command is run directly from its package directory:
```bash
cd apps/web
npm run test
```
`make lint` runs API Ruff and mypy checks plus frontend linting; `make typecheck` runs API mypy and frontend typechecking. Neither target runs frontend tests.
## Configuration ## Configuration
Key environment variables: Copy [`.env.example`](.env.example) to `.env` and replace its placeholder values rather than committing credentials. Key settings include:
| Variable | Description | Default | | Setting | Purpose |
|----------|-------------|---------| | --- | --- |
| `API_DOMAIN` | API domain | `localhost` | | `POSTGRES_USER`, `POSTGRES_PASSWORD`, `POSTGRES_DB` | PostgreSQL connection settings |
| `WEB_DOMAIN` | Web domain | `localhost` | | `REDIS_URL` | Redis connection URL |
| `AUTHENTIK_DOMAIN` | Authentik domain | - | | `SESSION_SECRET`, `SESSION_TTL_HOURS` | Session signing and lifetime |
| `AUTHENTIK_CLIENT_ID` | OAuth client ID | - | | `API_DOMAIN`, `WEB_DOMAIN` | Public API and web domains |
| `AUTHENTIK_CLIENT_SECRET` | OAuth client secret | - | | `AUTHENTIK_DOMAIN`, `AUTHENTIK_CLIENT_ID`, `AUTHENTIK_CLIENT_SECRET` | Authentik OAuth configuration |
| `DATABASE_URL` | PostgreSQL URL | - | | `AUTHENTIK_APPLICATION_SLUG` | Authentik application path component |
| `JWT_SECRET` | JWT signing secret | - | | `VITE_API_BASE_URL`, `VITE_APP_URL` | Frontend build-time public URLs |
| `REPO_BASE_PATH` | Repository storage path | `/data/repos` | | `REPO_BASE_PATH` | Repository storage path |
| `TRAEFIK_NETWORK` | Existing Traefik network for the production Compose stack |
See [Environment Variables](docs/deployment/environment.md) for complete list. The API reads `.env` settings and has development defaults, but defaults such as local credentials and session secrets are not suitable for production.
## Repository layout
```text
.
├── apps/
│ ├── api/ # FastAPI API, migrations, and tests
│ └── web/ # React/Vite frontend
├── docs/ # Architecture, API, feature, deployment, and development docs
├── e2e/ # Playwright end-to-end tests
├── docker-compose.yml # Local Compose stack
├── docker-compose.traefik.yml # Traefik deployment stack
└── Makefile # Compose, test, quality, and build commands
```
## License ## License
[License information] No license file or declared license was found in this checkout.
@@ -4,7 +4,7 @@ import {
getTerminalScrollbackLimit, getTerminalScrollbackLimit,
isCurrentWebSocket, isCurrentWebSocket,
shouldRetryWebSocketClose, shouldRetryWebSocketClose,
shouldCopyTerminalSelection, shouldShowTerminalCopyMenu,
} from "./terminal.tsx"; } from "./terminal.tsx";
describe("getTerminalScrollbackLimit", () => { describe("getTerminalScrollbackLimit", () => {
@@ -32,31 +32,12 @@ describe("getTerminalScrollbackLimit", () => {
}); });
}); });
describe("shouldCopyTerminalSelection", () => { describe("shouldShowTerminalCopyMenu", () => {
it("copies a selected terminal region with Ctrl+Shift+C", () => { it("shows Copy for selected terminal output", () => {
expect( expect(shouldShowTerminalCopyMenu("selected output")).toBe(true);
shouldCopyTerminalSelection(
{ ctrlKey: true, shiftKey: true, key: "c" },
true,
),
).toBe(true);
}); });
it("keeps Ctrl+C as a terminal interrupt", () => { it("keeps the native context menu when no output is selected", () => {
expect( expect(shouldShowTerminalCopyMenu("")).toBe(false);
shouldCopyTerminalSelection(
{ ctrlKey: true, shiftKey: false, key: "c" },
true,
),
).toBe(false);
});
it("does not copy without a selection", () => {
expect(
shouldCopyTerminalSelection(
{ ctrlKey: true, shiftKey: true, key: "c" },
false,
),
).toBe(false);
}); });
}); });
@@ -68,16 +68,20 @@ export function shouldRetryWebSocketClose(code: number, reason: string): boolean
return code !== 1000 && !(code === 4000 && reason === "New connection established"); return code !== 1000 && !(code === 4000 && reason === "New connection established");
} }
export function shouldCopyTerminalSelection( export function shouldShowTerminalCopyMenu(selection: string): boolean {
event: Pick<KeyboardEvent, "ctrlKey" | "shiftKey" | "key">, return selection.length > 0;
hasSelection: boolean, }
): boolean {
return ( function copyTextWithFallback(text: string): void {
hasSelection && const textarea = document.createElement("textarea");
event.ctrlKey && textarea.value = text;
event.shiftKey && textarea.setAttribute("readonly", "");
event.key.toLowerCase() === "c" textarea.style.position = "fixed";
); textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
textarea.remove();
} }
function matchesByteSequence( function matchesByteSequence(
@@ -119,6 +123,11 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
>("connecting"); >("connecting");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showResetConfirm, setShowResetConfirm] = useState(false); const [showResetConfirm, setShowResetConfirm] = useState(false);
const [copyMenu, setCopyMenu] = useState<{
x: number;
y: number;
text: string;
} | null>(null);
const activeModifierRef = useRef(activeModifier); const activeModifierRef = useRef(activeModifier);
activeModifierRef.current = activeModifier; activeModifierRef.current = activeModifier;
const [fontSize, setFontSize] = useState(() => { const [fontSize, setFontSize] = useState(() => {
@@ -481,28 +490,17 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
event.preventDefault(); event.preventDefault();
event.clipboardData?.setData("text/plain", selection); event.clipboardData?.setData("text/plain", selection);
}; };
const copySelection = () => { const handleTerminalContextMenu = (event: MouseEvent) => {
const selection = term.getSelection(); const selection = term.getSelection();
if (!selection) return; if (!shouldShowTerminalCopyMenu(selection)) {
setCopyMenu(null);
const textarea = document.createElement("textarea");
textarea.value = selection;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
textarea.remove();
};
const handleBrowserCopyShortcut = (event: KeyboardEvent) => {
if (!shouldCopyTerminalSelection(event, term.hasSelection())) {
return; return;
} }
event.preventDefault(); event.preventDefault();
event.stopImmediatePropagation();
event.stopPropagation(); event.stopPropagation();
copySelection(); setCopyMenu({ x: event.clientX, y: event.clientY, text: selection });
}; };
const handleBrowserPaste = (event: ClipboardEvent) => { const handleBrowserPaste = (event: ClipboardEvent) => {
@@ -516,7 +514,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
pasteTextRef.current(text); pasteTextRef.current(text);
}; };
container.addEventListener("copy", handleBrowserCopy, true); container.addEventListener("copy", handleBrowserCopy, true);
container.addEventListener("keydown", handleBrowserCopyShortcut, true); container.addEventListener("contextmenu", handleTerminalContextMenu, true);
container.addEventListener("paste", handleBrowserPaste, true); container.addEventListener("paste", handleBrowserPaste, true);
// Mobile touch scroll. // Mobile touch scroll.
@@ -766,8 +764,8 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
if (touchCleanup) touchCleanup(); if (touchCleanup) touchCleanup();
container.removeEventListener("copy", handleBrowserCopy, true); container.removeEventListener("copy", handleBrowserCopy, true);
container.removeEventListener( container.removeEventListener(
"keydown", "contextmenu",
handleBrowserCopyShortcut, handleTerminalContextMenu,
true, true,
); );
container.removeEventListener("paste", handleBrowserPaste, true); container.removeEventListener("paste", handleBrowserPaste, true);
@@ -904,6 +902,7 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
// Focus terminal on mobile to keep keyboard open // Focus terminal on mobile to keep keyboard open
const handleTerminalClick = () => { const handleTerminalClick = () => {
setCopyMenu(null);
if (isMobile && termRef.current) { if (isMobile && termRef.current) {
termRef.current.focus(); termRef.current.focus();
} }
@@ -1018,6 +1017,26 @@ export const TerminalComponent = React.forwardRef<TerminalRef, TerminalProps>(
</div> </div>
</div> </div>
)} )}
{copyMenu && (
<button
aria-label="Copy selected terminal text"
className="terminal-context-copy"
onMouseDown={(event) => {
event.preventDefault();
copyTextWithFallback(copyMenu.text);
setCopyMenu(null);
}}
style={{
left: copyMenu.x,
position: "fixed",
top: copyMenu.y,
zIndex: 1000,
}}
type="button"
>
Copy
</button>
)}
{error && ( {error && (
<div className="terminal-error"> <div className="terminal-error">
{error} {error}
@@ -6,8 +6,9 @@ Users cannot reliably copy terminal output from the browser terminal. Browser co
## Required behavior ## Required behavior
- Copying a selected terminal region with `Ctrl+Shift+C` copies text to the system clipboard without sending input to the terminal. - Right-clicking a selected terminal region presents an xterm-aware Copy action that writes the selection to the system clipboard.
- `Ctrl+C` remains a terminal interrupt, including when output is selected. - Browser native context menus remain available when no terminal output is selected.
- Terminal keyboard input, including `Ctrl+C`, remains unchanged.
- Copy requests expose the xterm selection as plain text. - Copy requests expose the xterm selection as plain text.
- Pasting plain text is handled once and follows bracketed-paste mode when enabled. - Pasting plain text is handled once and follows bracketed-paste mode when enabled.
- Normal terminal interrupts still work when there is no active selection. - Normal terminal interrupts still work when there is no active selection.