diff --git a/plugins/code-server/package.json b/plugins/code-server/package.json index 3fabbf6..fd82749 100644 --- a/plugins/code-server/package.json +++ b/plugins/code-server/package.json @@ -42,6 +42,7 @@ "scripts": { "build": "tsdown && vite build --config src/spa/vite.config.ts", "watch": "tsdown --watch", + "typecheck": "tsc --noEmit", "dev": "vite --config src/spa/vite.config.ts --host 0.0.0.0", "storybook": "storybook dev -p 6006 --host 0.0.0.0", "build-storybook": "storybook build", diff --git a/plugins/code-server/tsconfig.json b/plugins/code-server/tsconfig.json index 8a6d5a7..1b1d87c 100644 --- a/plugins/code-server/tsconfig.json +++ b/plugins/code-server/tsconfig.json @@ -1,7 +1,8 @@ { "extends": "../../tsconfig.base.json", "compilerOptions": { - "composite": true, "lib": ["esnext", "dom"] - } + }, + "include": ["src", "test", "tsdown.config.ts"], + "exclude": ["dist", "src/spa/dist", ".next", "out", "node_modules"] } diff --git a/plugins/git/.gitignore b/plugins/git/.gitignore new file mode 100644 index 0000000..0d65ee7 --- /dev/null +++ b/plugins/git/.gitignore @@ -0,0 +1,7 @@ +.next +dist +next-env.d.ts +node_modules +out +.turbo +storybook-static diff --git a/plugins/git/.storybook/main.ts b/plugins/git/.storybook/main.ts new file mode 100644 index 0000000..9610717 --- /dev/null +++ b/plugins/git/.storybook/main.ts @@ -0,0 +1,18 @@ +import type { StorybookConfig } from '@storybook/react-vite' +import tailwindcss from '@tailwindcss/vite' + +const config: StorybookConfig = { + stories: ['../src/client/**/*.stories.@(ts|tsx)'], + addons: ['@storybook/addon-docs', '@storybook/addon-a11y'], + framework: { + name: '@storybook/react-vite', + options: {}, + }, + viteFinal(viteConfig) { + viteConfig.plugins ??= [] + viteConfig.plugins.push(tailwindcss()) + return viteConfig + }, +} + +export default config diff --git a/plugins/git/.storybook/preview.tsx b/plugins/git/.storybook/preview.tsx new file mode 100644 index 0000000..d65afa6 --- /dev/null +++ b/plugins/git/.storybook/preview.tsx @@ -0,0 +1,42 @@ +import type { Decorator, Preview } from '@storybook/react-vite' +import { useEffect } from 'react' +import '../src/client/app/globals.css' + +const withTheme: Decorator = (Story, context) => { + const theme = context.globals.theme ?? 'dark' + useEffect(() => { + document.documentElement.classList.toggle('dark', theme === 'dark') + }, [theme]) + return ( +
+
+ +
+
+ ) +} + +const preview: Preview = { + parameters: { + layout: 'fullscreen', + controls: { expanded: true }, + }, + globalTypes: { + theme: { + description: 'Color theme', + defaultValue: 'dark', + toolbar: { + title: 'Theme', + icon: 'contrast', + items: [ + { value: 'light', title: 'Light', icon: 'sun' }, + { value: 'dark', title: 'Dark', icon: 'moon' }, + ], + dynamicTitle: true, + }, + }, + }, + decorators: [withTheme], +} + +export default preview diff --git a/plugins/git/LICENSE.md b/plugins/git/LICENSE.md new file mode 100644 index 0000000..09e688c --- /dev/null +++ b/plugins/git/LICENSE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026-PRESENT Anthony Fu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/git/README.md b/plugins/git/README.md new file mode 100644 index 0000000..57be136 --- /dev/null +++ b/plugins/git/README.md @@ -0,0 +1,90 @@ +# @devframes/plugin-git + +Git integration for [devframe](https://github.com/devframes/devframe) — a +repository dashboard with a **Next.js App Router + shadcn/ui** SPA over +type-safe RPC. The host process shells out to `git` and exposes the repository; +the same bundle runs as a live dev server or a fully static deployment. + +Status, a SourceTree-style **commit graph**, branches, and diffs are read-only; +staging, unstaging, and committing are available when write mode is enabled. The +UI follows the system **light/dark** preference with a manual toggle. + +## Install + +```sh +npm i -D @devframes/plugin-git +``` + +## Standalone CLI + +Run the dashboard against the current repository: + +```sh +npx devframe-git # dev server (live RPC over WebSocket) +npx devframe-git --write # also enable staging / committing from the UI +npx devframe-git build # static deploy → dist-static/ +npx devframe-git --port 4000 +``` + +## Programmatic + +`createGitDevframe(options)` returns a devframe definition you can mount into +any host with devframe's adapters, or drive yourself. + +```ts +import { createGitDevframe } from '@devframes/plugin-git' +import { createCli } from 'devframe/adapters/cli' + +await createCli(createGitDevframe({ repoRoot: process.cwd() })).parse() +``` + +| Option | Default | Description | +|--------|---------|-------------| +| `repoRoot` | the devframe `cwd` | Repository directory to inspect. | +| `basePath` | adapter-resolved | Mount path (`/` standalone, `/__git/` hosted). | +| `distDir` | bundled SPA | Override the served SPA directory. | +| `port` | `9710` | Preferred dev-server port. | +| `write` | `false` | Enable staging, unstaging, and committing from the UI. | + +## RPC surface + +The read functions are each a `query` with `snapshot: true`: resolved live over +WebSocket in dev, and served from a snapshot baked at build time for static +deploys. Each degrades to an empty, `isRepo: false` result outside a git +repository. + +- `git:status` — branch, upstream tracking (ahead/behind), staged / unstaged / + untracked files, parsed from `git status --porcelain=v2`. Reports `canWrite`. +- `git:log` — paginated commit history (`limit` / `skip`) including parent + hashes, which drive the commit graph. +- `git:branches` — local branches with SHA, upstream, ahead/behind, tip subject. +- `git:diff` — per-file added/deleted counts for the working tree or index, plus + a unified patch for a selected file. + +Write actions are `action` functions, registered only when write mode is enabled +(`createGitDevframe({ write: true })` or the `--write` flag) and gated behind +`status.canWrite` in the UI. Each returns fresh status (commit returns a result): + +- `git:stage` — `git add` the given paths. +- `git:unstage` — `git restore --staged` the given paths. +- `git:commit` — commit the staged changes with a message. + +## Develop + +```sh +pnpm -C plugins/git dev # client (Next.js HMR) + RPC backend together +pnpm -C plugins/git build # tsdown (node) + next build (SPA) → dist/ +``` + +`pnpm dev` starts the Next.js dev server (with hot-reload) and the devframe +RPC/WebSocket backend at the same time, then prints both URLs — open the UI one. +The SPA connects to the backend over the WebSocket port carried in +`NEXT_PUBLIC_DEVFRAME_WS`. Override ports with `PORT` (UI) and +`DEVFRAME_GIT_PORT` (backend). Run a single side with `dev:client` or +`dev:server`. + +The SPA is a standard shadcn/ui setup (Tailwind v4, `components/ui/*`). Three +Next.js settings in `src/client/next.config.mjs` keep it portable: `output: +'export'` (devframe owns the server), `assetPrefix: '.'` (relative assets so the +same bundle works at any base), and `trailingSlash: true` (composes with +devframe's static directory-with-index resolution). diff --git a/plugins/git/bin.mjs b/plugins/git/bin.mjs new file mode 100755 index 0000000..8287d1a --- /dev/null +++ b/plugins/git/bin.mjs @@ -0,0 +1,3 @@ +#!/usr/bin/env node +// Thin launcher for the published package — runs the compiled CLI entry. +import './dist/cli.mjs' diff --git a/plugins/git/package.json b/plugins/git/package.json new file mode 100644 index 0000000..c6f1432 --- /dev/null +++ b/plugins/git/package.json @@ -0,0 +1,81 @@ +{ + "name": "@devframes/plugin-git", + "type": "module", + "version": "0.5.2", + "description": "Git integration for devframe — a read-only repository dashboard (status, log, branches, diff) with a Next.js + shadcn/ui SPA over type-safe RPC.", + "author": "Anthony Fu ", + "license": "MIT", + "homepage": "https://github.com/devframes/devframe#readme", + "repository": { + "directory": "plugins/git", + "type": "git", + "url": "git+https://github.com/devframes/devframe.git" + }, + "bugs": "https://github.com/devframes/devframe/issues", + "keywords": [ + "devtools", + "devframe", + "git", + "dashboard", + "plugin" + ], + "sideEffects": false, + "exports": { + ".": "./dist/index.mjs", + "./package.json": "./package.json" + }, + "types": "./dist/index.d.mts", + "bin": { + "devframe-git": "./bin.mjs" + }, + "files": [ + "bin.mjs", + "dist" + ], + "scripts": { + "build": "tsdown && pnpm run build:spa", + "build:spa": "next build src/client && rm -rf dist/client && cp -r src/client/out dist/client", + "cli:build": "node bin.mjs build --out-dir dist/static", + "dev": "node scripts/dev.mjs", + "dev:server": "node src/cli.ts", + "dev:client": "next dev src/client", + "storybook": "storybook dev -p 6006", + "storybook:build": "storybook build -o storybook-static", + "watch": "tsdown --watch", + "typecheck": "tsc --noEmit", + "prepack": "pnpm run build", + "test": "vitest run" + }, + "dependencies": { + "devframe": "workspace:*", + "pathe": "catalog:deps" + }, + "devDependencies": { + "@radix-ui/react-scroll-area": "catalog:frontend", + "@radix-ui/react-separator": "catalog:frontend", + "@radix-ui/react-slot": "catalog:frontend", + "@radix-ui/react-tabs": "catalog:frontend", + "@storybook/addon-a11y": "catalog:frontend", + "@storybook/addon-docs": "catalog:frontend", + "@storybook/react-vite": "catalog:frontend", + "@tailwindcss/postcss": "catalog:frontend", + "@tailwindcss/vite": "catalog:frontend", + "@types/react": "catalog:types", + "@types/react-dom": "catalog:types", + "class-variance-authority": "catalog:frontend", + "clsx": "catalog:frontend", + "get-port-please": "catalog:deps", + "h3": "catalog:deps", + "lucide-react": "catalog:frontend", + "next": "catalog:frontend", + "react": "catalog:frontend", + "react-dom": "catalog:frontend", + "storybook": "catalog:frontend", + "tailwind-merge": "catalog:frontend", + "tailwindcss": "catalog:frontend", + "tsdown": "catalog:build", + "tw-animate-css": "catalog:frontend", + "vitest": "catalog:testing", + "ws": "catalog:deps" + } +} diff --git a/plugins/git/scripts/dev.mjs b/plugins/git/scripts/dev.mjs new file mode 100644 index 0000000..880a136 --- /dev/null +++ b/plugins/git/scripts/dev.mjs @@ -0,0 +1,55 @@ +// Runs the devframe RPC/WebSocket backend and the Next.js dev server (with +// HMR) together. Open the UI URL printed below; the client connects to the +// backend over WebSocket via NEXT_PUBLIC_DEVFRAME_WS. +import { spawn } from 'node:child_process' +import { createRequire } from 'node:module' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +const require = createRequire(import.meta.url) +const root = fileURLToPath(new URL('..', import.meta.url)) +const host = process.env.HOST ?? '0.0.0.0' +const serverPort = process.env.DEVFRAME_GIT_PORT ?? '9710' +const clientPort = process.env.PORT ?? '3000' +// Resolve the Next.js bin explicitly so this works regardless of PATH. +const nextBin = require.resolve('next/dist/bin/next') + +const children = [ + // RPC + WebSocket backend (devframe). Serves the prebuilt SPA too, but in + // dev you open the Next server below for hot-reloading. + spawn(process.execPath, ['src/cli.ts', '--port', serverPort, '--host', host], { + cwd: root, + stdio: 'inherit', + env: process.env, + }), + // Next.js dev server (HMR). Points the client at the backend WebSocket. + spawn(process.execPath, [nextBin, 'dev', 'src/client', '--port', clientPort, '--hostname', host], { + cwd: root, + stdio: 'inherit', + env: { ...process.env, NEXT_PUBLIC_DEVFRAME_WS: serverPort }, + }), +] + +console.error(`\n @devframes/plugin-git dev`) +console.error(` UI (HMR): http://localhost:${clientPort}`) +console.error(` RPC backend: http://localhost:${serverPort}\n`) + +let shuttingDown = false +function shutdown(code = 0) { + if (shuttingDown) + return + shuttingDown = true + for (const child of children) + child.kill('SIGTERM') + process.exit(code) +} + +process.on('SIGINT', () => shutdown(0)) +process.on('SIGTERM', () => shutdown(0)) +for (const child of children) { + child.on('exit', code => shutdown(code ?? 0)) + child.on('error', (error) => { + console.error(error) + shutdown(1) + }) +} diff --git a/plugins/git/src/cli.ts b/plugins/git/src/cli.ts new file mode 100644 index 0000000..57bd383 --- /dev/null +++ b/plugins/git/src/cli.ts @@ -0,0 +1,16 @@ +import process from 'node:process' +import { createCli } from 'devframe/adapters/cli' +import { createGitDevframe } from './index.ts' + +const cli = createCli(createGitDevframe(), { + onReady({ origin }) { + // devframe is headless by default — print our own ready banner so the + // dev server doesn't look like it silently did nothing. + console.error(`\n @devframes/plugin-git ready at ${origin}\n`) + }, +}) + +cli.parse().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/plugins/git/src/client/app/globals.css b/plugins/git/src/client/app/globals.css new file mode 100644 index 0000000..cfb0152 --- /dev/null +++ b/plugins/git/src/client/app/globals.css @@ -0,0 +1,96 @@ +@import "tailwindcss"; +@import "tw-animate-css"; + +/* Explicit source globs so class detection covers app/ and components/. */ +@source "../**/*.{ts,tsx}"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --radius: 0.625rem; + --background: oklch(1 0 0); + --foreground: oklch(0.145 0 0); + --card: oklch(1 0 0); + --card-foreground: oklch(0.145 0 0); + --popover: oklch(1 0 0); + --popover-foreground: oklch(0.145 0 0); + --primary: oklch(0.205 0 0); + --primary-foreground: oklch(0.985 0 0); + --secondary: oklch(0.97 0 0); + --secondary-foreground: oklch(0.205 0 0); + --muted: oklch(0.97 0 0); + --muted-foreground: oklch(0.556 0 0); + --accent: oklch(0.97 0 0); + --accent-foreground: oklch(0.205 0 0); + --destructive: oklch(0.577 0.245 27.325); + --border: oklch(0.922 0 0); + --input: oklch(0.922 0 0); + --ring: oklch(0.708 0 0); + --success: oklch(0.6 0.13 160); + --warning: oklch(0.75 0.15 80); +} + +.dark { + --background: oklch(0.145 0 0); + --foreground: oklch(0.985 0 0); + --card: oklch(0.205 0 0); + --card-foreground: oklch(0.985 0 0); + --popover: oklch(0.205 0 0); + --popover-foreground: oklch(0.985 0 0); + --primary: oklch(0.922 0 0); + --primary-foreground: oklch(0.205 0 0); + --secondary: oklch(0.269 0 0); + --secondary-foreground: oklch(0.985 0 0); + --muted: oklch(0.269 0 0); + --muted-foreground: oklch(0.708 0 0); + --accent: oklch(0.269 0 0); + --accent-foreground: oklch(0.985 0 0); + --destructive: oklch(0.704 0.191 22.216); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 15%); + --ring: oklch(0.556 0 0); + --success: oklch(0.7 0.14 160); + --warning: oklch(0.8 0.15 80); +} + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-success: var(--success); + --color-warning: var(--warning); + --font-mono: ui-monospace, SFMono-Regular, "Cascadia Code", "Menlo", monospace; +} + +@layer base { + * { + border-color: var(--color-border); + outline-color: color-mix(in oklab, var(--color-ring) 50%, transparent); + } + + body { + background-color: var(--color-background); + color: var(--color-foreground); + font-family: system-ui, -apple-system, "Segoe UI", sans-serif; + -webkit-font-smoothing: antialiased; + } +} diff --git a/plugins/git/src/client/app/layout.tsx b/plugins/git/src/client/app/layout.tsx new file mode 100644 index 0000000..012843d --- /dev/null +++ b/plugins/git/src/client/app/layout.tsx @@ -0,0 +1,22 @@ +import type { Metadata } from 'next' +import type { ReactNode } from 'react' +import './globals.css' + +export const metadata: Metadata = { + title: 'Git Dashboard', + description: 'A devframe Git integration with a Next.js App Router + shadcn/ui SPA.', +} + +// Set the theme class before paint to avoid a flash of the wrong theme. +const themeScript = `(function(){try{var k='devframe-git-theme';var t=localStorage.getItem(k);if(t!=='light'&&t!=='dark'){t=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}if(t==='dark')document.documentElement.classList.add('dark');}catch(e){document.documentElement.classList.add('dark');}})();` + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + +