This commit is contained in:
암냥 2026-07-03 00:36:40 +09:00
commit ba26116797
No known key found for this signature in database
67 changed files with 18730 additions and 0 deletions

18
.cta.json Normal file
View file

@ -0,0 +1,18 @@
{
"projectName": "githikari",
"mode": "file-router",
"typescript": true,
"packageManager": "bun",
"includeExamples": true,
"tailwind": true,
"addOnOptions": {},
"envVarValues": {},
"git": true,
"install": true,
"routerOnly": false,
"version": 1,
"framework": "react",
"chosenAddOns": [
"biome"
]
}

7
.envrc Normal file
View file

@ -0,0 +1,7 @@
#!/usr/bin/env bash
eval "$(devenv direnvrc)"
# You can pass flags to the devenv command
# For example: use devenv --impure --option services.postgres.enable:bool true
use devenv

16
.gitignore vendored Normal file
View file

@ -0,0 +1,16 @@
node_modules
.DS_Store
dist
dist-ssr
*.local
.env
.nitro
.tanstack
.wrangler
.output
.vinxi
__unconfig*
todos.json
.devenv
.direnv
.vscode

204
README.md Normal file
View file

@ -0,0 +1,204 @@
Welcome to your new TanStack Start app!
# Getting Started
To run this application:
```bash
bun install
bun --bun run dev
```
# Building For Production
To build this application for production:
```bash
bun --bun run build
```
## Testing
This project uses [Vitest](https://vitest.dev/) for testing. You can run the tests with:
```bash
bun --bun run test
```
## Styling
This project uses [Tailwind CSS](https://tailwindcss.com/) for styling.
### Removing Tailwind CSS
If you prefer not to use Tailwind CSS:
1. Remove the demo pages in `src/routes/demo/`
2. Replace the Tailwind import in `src/styles.css` with your own styles
3. Remove `tailwindcss()` from the plugins array in `vite.config.ts`
4. Uninstall the packages: `bun install @tailwindcss/vite tailwindcss -D`
## Linting & Formatting
This project uses [Biome](https://biomejs.dev/) for linting and formatting. The following scripts are available:
```bash
bun --bun run lint
bun --bun run format
bun --bun run check
```
## Routing
This project uses [TanStack Router](https://tanstack.com/router) with file-based routing. Routes are managed as files in `src/routes`.
### Adding A Route
To add a new route to your application just add a new file in the `./src/routes` directory.
TanStack will automatically generate the content of the route file for you.
Now that you have two routes you can use a `Link` component to navigate between them.
### Adding Links
To use SPA (Single Page Application) navigation you will need to import the `Link` component from `@tanstack/react-router`.
```tsx
import { Link } from "@tanstack/react-router";
```
Then anywhere in your JSX you can use it like so:
```tsx
<Link to="/about">About</Link>
```
This will create a link that will navigate to the `/about` route.
More information on the `Link` component can be found in the [Link documentation](https://tanstack.com/router/v1/docs/framework/react/api/router/linkComponent).
### Using A Layout
In the File Based Routing setup the layout is located in `src/routes/__root.tsx`. Anything you add to the root route will appear in all the routes. The route content will appear in the JSX where you render `{children}` in the `shellComponent`.
Here is an example layout that includes a header:
```tsx
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: 'utf-8' },
{ name: 'viewport', content: 'width=device-width, initial-scale=1' },
{ title: 'My App' },
],
}),
shellComponent: ({ children }) => (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
<header>
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
</nav>
</header>
{children}
<Scripts />
</body>
</html>
),
})
```
More information on layouts can be found in the [Layouts documentation](https://tanstack.com/router/latest/docs/framework/react/guide/routing-concepts#layouts).
## Server Functions
TanStack Start provides server functions that allow you to write server-side code that seamlessly integrates with your client components.
```tsx
import { createServerFn } from '@tanstack/react-start'
const getServerTime = createServerFn({
method: 'GET',
}).handler(async () => {
return new Date().toISOString()
})
// Use in a component
function MyComponent() {
const [time, setTime] = useState('')
useEffect(() => {
getServerTime().then(setTime)
}, [])
return <div>Server time: {time}</div>
}
```
## API Routes
You can create API routes by using the `server` property in your route definitions:
```tsx
import { createFileRoute } from '@tanstack/react-router'
import { json } from '@tanstack/react-start'
export const Route = createFileRoute('/api/hello')({
server: {
handlers: {
GET: () => json({ message: 'Hello, World!' }),
},
},
})
```
## Data Fetching
There are multiple ways to fetch data in your application. You can use TanStack Query to fetch data from a server. But you can also use the `loader` functionality built into TanStack Router to load the data for a route before it's rendered.
For example:
```tsx
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/people')({
loader: async () => {
const response = await fetch('https://swapi.dev/api/people')
return response.json()
},
component: PeopleComponent,
})
function PeopleComponent() {
const data = Route.useLoaderData()
return (
<ul>
{data.results.map((person) => (
<li key={person.name}>{person.name}</li>
))}
</ul>
)
}
```
Loaders simplify your data fetching logic dramatically. Check out more information in the [Loader documentation](https://tanstack.com/router/latest/docs/framework/react/guide/data-loading#loader-parameters).
# Demo files
Files prefixed with `demo` can be safely deleted. They are there to provide a starting point for you to play around with the features you've installed.
# Learn More
You can learn more about all of the offerings from TanStack in the [TanStack documentation](https://tanstack.com).
For TanStack Start specific documentation, visit [TanStack Start](https://tanstack.com/start).

36
biome.json Normal file
View file

@ -0,0 +1,36 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.4/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
"useIgnoreFile": false
},
"files": {
"ignoreUnknown": false,
"includes": [
"**/src/**/*",
"**/.vscode/**/*",
"**/index.html",
"**/vite.config.ts",
"!**/src/routeTree.gen.ts",
"!**/src/styles.css"
]
},
"formatter": {
"enabled": true,
"indentStyle": "tab"
},
"assist": { "actions": { "source": { "organizeImports": "on" } } },
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
}
}
}

1514
bun.lock Normal file

File diff suppressed because it is too large Load diff

25
components.json Normal file
View file

@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/styles.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide",
"rtl": false,
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

65
devenv.lock Normal file
View file

@ -0,0 +1,65 @@
{
"nodes": {
"devenv": {
"locked": {
"dir": "src/modules",
"lastModified": 1782938471,
"narHash": "sha256-m//AHi+NJN+1eTTdEaL3oXcuTHy1XtV6XiyGtsP9PJE=",
"owner": "cachix",
"repo": "devenv",
"rev": "46197d1e4c2ca0cc1f0635927b3bee3d62b51779",
"type": "github"
},
"original": {
"dir": "src/modules",
"owner": "cachix",
"repo": "devenv",
"type": "github"
}
},
"nixpkgs": {
"inputs": {
"nixpkgs-src": "nixpkgs-src"
},
"locked": {
"lastModified": 1782924808,
"narHash": "sha256-tn2ahNv3ZNXyVHdPx/uw+N0xa7YSMtXUr6OEvu+s8ng=",
"owner": "cachix",
"repo": "devenv-nixpkgs",
"rev": "e2c881cb8d5f1cac6cef9d71f0d450a55691b999",
"type": "github"
},
"original": {
"owner": "cachix",
"ref": "rolling",
"repo": "devenv-nixpkgs",
"type": "github"
}
},
"nixpkgs-src": {
"flake": false,
"locked": {
"lastModified": 1782636521,
"narHash": "sha256-OG8laCOGtkxlB1JH3XqOnXxnkGJme4mQdXo5hkhCQIY=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "e1c1b84752fb0897897380a3cae9dc7fcab91ca3",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"devenv": "devenv",
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}

36
devenv.nix Normal file
View file

@ -0,0 +1,36 @@
{ pkgs, ... }:
{
packages = with pkgs; [
bun
nodejs_24
rustc
cargo
rustfmt
clippy
pkg-config
openssl
git
webkitgtk_4_1
gtk3
glib
cairo
pango
gdk-pixbuf
libsoup_3
librsvg
dbus
at-spi2-atk
xdotool
];
env = {
GIO_MODULE_DIR = "${pkgs.glib-networking}/lib/gio/modules";
WEBKIT_DISABLE_COMPOSITING_MODE = "1";
};
scripts.check.exec = ''
bun run build
cargo check --manifest-path src-tauri/Cargo.toml
'';
}

14
index.html Normal file
View file

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tauri + React + Typescript</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

70
package.json Normal file
View file

@ -0,0 +1,70 @@
{
"name": "githikari",
"private": true,
"type": "module",
"imports": {
"#/*": "./src/*"
},
"scripts": {
"dev": "vite dev --port 1420",
"generate-routes": "tsr generate",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run",
"format": "biome format",
"lint": "biome lint",
"check": "biome check",
"tauri": "tauri",
"tauri:dev": "tauri dev"
},
"dependencies": {
"@fontsource-variable/inter": "^5.2.8",
"@pierre/diffs": "^1.2.12",
"@pierre/trees": "^1.0.0-beta.5",
"@tailwindcss/vite": "^4.1.18",
"@tanstack/react-devtools": "latest",
"@tanstack/react-router": "latest",
"@tanstack/react-router-devtools": "latest",
"@tanstack/react-router-ssr-query": "latest",
"@tanstack/react-start": "latest",
"@tanstack/router-plugin": "^1.132.0",
"@tauri-apps/api": "^2.11.1",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@wterm/core": "^0.3.0",
"@wterm/dom": "^0.3.0",
"@wterm/react": "^0.3.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.545.0",
"radix-ui": "^1.6.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"shadcn": "^4.12.0",
"tailwind-merge": "^3.6.0",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@biomejs/biome": "2.4.5",
"@tailwindcss/typography": "^0.5.16",
"@tanstack/devtools-vite": "latest",
"@tanstack/router-cli": "^1.132.0",
"@tauri-apps/cli": "^2.11.4",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.0",
"@types/node": "^22.10.2",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^6.0.1",
"jsdom": "^28.1.0",
"typescript": "^6.0.2",
"vite": "^8.0.0",
"vitest": "^4.1.5"
},
"pnpm": {
"onlyBuiltDependencies": [
"esbuild",
"lightningcss"
]
}
}

7662
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load diff

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

16
public/logo.svg Normal file
View file

@ -0,0 +1,16 @@
<svg width="114" height="114" viewBox="0 0 114 114" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M111.78 51.8875L62.0349 2.1486C59.1727 -0.7162 54.5267 -0.7162 51.6609 2.1486L41.3319 12.4786L54.4339 25.5806C57.4798 24.5522 60.971 25.2417 63.3978 27.669C65.8372 30.1114 66.5218 33.6324 65.4676 36.6885L78.0956 49.3165C81.1507 48.2637 84.6756 48.9439 87.1151 51.3877C90.5257 54.7973 90.5257 60.3222 87.1151 63.7327C83.704 67.1443 78.1791 67.1443 74.7661 63.7327C72.2016 61.1662 71.5673 57.3982 72.8662 54.2385L61.0892 42.4615L61.0882 73.4525C61.9197 73.8641 62.7044 74.4135 63.3973 75.1034C66.8069 78.5126 66.8069 84.0365 63.3973 87.4514C59.9867 90.8605 54.4593 90.8605 51.0523 87.4514C47.6422 84.0368 47.6422 78.5129 51.0523 75.1034C51.895 74.2622 52.8702 73.6254 53.9107 73.1986V41.9196C52.8697 41.4946 51.8957 40.8626 51.0517 40.0146C48.4687 37.4336 47.8466 33.6426 49.1713 30.4707L36.2553 17.5527L2.14928 51.6577C-0.716425 54.5247 -0.716425 59.1707 2.14928 62.0357L51.8913 111.775C54.7551 114.639 59.3995 114.639 62.2673 111.775L111.779 62.2707C114.644 59.4045 114.644 54.7571 111.779 51.8917" fill="url(#paint0_linear_372_6)"/>
<defs>
<linearGradient id="paint0_linear_372_6" x1="0" y1="0" x2="114" y2="114" gradientUnits="userSpaceOnUse">
<stop stop-color="#FFB5C9"/>
<stop offset="0.125" stop-color="#FFB8C7"/>
<stop offset="0.25" stop-color="#FFBCC4"/>
<stop offset="0.375" stop-color="#FFBFC2"/>
<stop offset="0.5" stop-color="#FFC3BF"/>
<stop offset="0.625" stop-color="#FFC6BD"/>
<stop offset="0.75" stop-color="#FFC9BA"/>
<stop offset="0.875" stop-color="#FFCDB8"/>
<stop offset="1" stop-color="#FFD0B5"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
public/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
public/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

25
public/manifest.json Normal file
View file

@ -0,0 +1,25 @@
{
"short_name": "TanStack App",
"name": "Create TanStack App Sample",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"type": "image/png",
"sizes": "512x512"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
}

6
public/tauri.svg Normal file
View file

@ -0,0 +1,6 @@
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

1
public/vite.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

7
src-tauri/.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
# Generated by Cargo
# will have compiled files and executables
/target/
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas

5307
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

26
src-tauri/Cargo.toml Normal file
View file

@ -0,0 +1,26 @@
[package]
name = "githikari"
version = "0.1.0"
description = "A Tauri App"
authors = ["you"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# The `_lib` suffix may seem redundant but it is necessary
# to make the lib name unique and wouldn't conflict with the bin name.
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
name = "githikari_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-dialog = { version = "2.7.1", default-features = false, features = ["xdg-portal"] }
tauri-plugin-opener = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
portable-pty = "0.9.0"

3
src-tauri/build.rs Normal file
View file

@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View file

@ -0,0 +1,13 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"dialog:allow-open",
"opener:default",
"opener:allow-open-path",
"opener:allow-reveal-item-in-dir"
]
}

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 974 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

913
src-tauri/src/lib.rs Normal file
View file

@ -0,0 +1,913 @@
use portable_pty::{native_pty_system, ChildKiller, CommandBuilder, MasterPty, PtySize};
use serde::Serialize;
use std::{
collections::HashMap,
env,
io::{Read, Write},
path::{Path, PathBuf},
process::Command,
sync::{Arc, Mutex},
thread,
};
use tauri::{AppHandle, Emitter, State};
struct TerminalSession {
master: Box<dyn MasterPty + Send>,
writer: Arc<Mutex<Box<dyn Write + Send>>>,
killer: Box<dyn ChildKiller + Send + Sync>,
}
#[derive(Default)]
struct TerminalState {
sessions: Arc<Mutex<HashMap<String, TerminalSession>>>,
}
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
struct TerminalOutput {
id: String,
data: String,
}
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
struct TerminalExit {
id: String,
code: u32,
signal: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct GitStatusEntry {
path: String,
status: String,
index_status: String,
worktree_status: String,
old_path: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct GitStatusSummary {
staged: usize,
unstaged: usize,
untracked: usize,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct GitRecentCommit {
hash: String,
subject: String,
author: String,
relative_time: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct GitRepositoryStatus {
root_path: String,
branch: String,
head: Option<String>,
ahead: usize,
behind: usize,
entries: Vec<GitStatusEntry>,
summary: GitStatusSummary,
recent_commits: Vec<GitRecentCommit>,
}
const PRIMARY_DEVICE_ATTRIBUTES_RESPONSE: &[u8] = b"\x1b[?1;2c";
const TERMINAL_QUERIES_TO_ANSWER: [&[u8]; 2] = [b"\x1b[c", b"\x1b[0c"];
#[derive(Default)]
struct TerminalOutputFilter {
pending: Vec<u8>,
}
impl TerminalOutputFilter {
fn push(&mut self, input: &[u8]) -> (Vec<u8>, usize) {
let mut source = Vec::with_capacity(self.pending.len() + input.len());
source.extend_from_slice(&self.pending);
source.extend_from_slice(input);
self.pending.clear();
let mut output = Vec::with_capacity(source.len());
let mut response_count = 0;
let mut index = 0;
while index < source.len() {
if let Some(query) = TERMINAL_QUERIES_TO_ANSWER
.iter()
.find(|query| source[index..].starts_with(query))
{
response_count += 1;
index += query.len();
continue;
}
if TERMINAL_QUERIES_TO_ANSWER
.iter()
.any(|query| query.starts_with(&source[index..]))
{
self.pending.extend_from_slice(&source[index..]);
break;
}
output.push(source[index]);
index += 1;
}
(output, response_count)
}
}
fn terminal_size(cols: u16, rows: u16) -> PtySize {
PtySize {
cols,
rows,
pixel_width: 0,
pixel_height: 0,
}
}
fn default_shell() -> String {
env::var("SHELL")
.ok()
.filter(|shell| !shell.trim().is_empty())
.unwrap_or_else(|| "/bin/sh".to_string())
}
#[tauri::command]
fn spawn_terminal(
app: AppHandle,
state: State<'_, TerminalState>,
id: String,
cols: u16,
rows: u16,
cwd: Option<String>,
shell: Option<String>,
) -> Result<(), String> {
kill_terminal(state.clone(), id.clone()).ok();
let pty_system = native_pty_system();
let pair = pty_system
.openpty(terminal_size(cols, rows))
.map_err(|error| error.to_string())?;
let mut command = CommandBuilder::new(shell.unwrap_or_else(default_shell));
command.env("TERM", "xterm-256color");
command.env("COLORTERM", "truecolor");
if let Some(cwd) = cwd.filter(|cwd| !cwd.trim().is_empty()) {
command.cwd(cwd);
}
let mut child = pair
.slave
.spawn_command(command)
.map_err(|error| error.to_string())?;
let mut reader = pair
.master
.try_clone_reader()
.map_err(|error| error.to_string())?;
let writer = Arc::new(Mutex::new(
pair.master
.take_writer()
.map_err(|error| error.to_string())?,
));
let killer = child.clone_killer();
let output_writer = writer.clone();
{
let mut sessions = state.sessions.lock().map_err(|error| error.to_string())?;
sessions.insert(
id.clone(),
TerminalSession {
master: pair.master,
writer,
killer,
},
);
}
let output_app = app.clone();
let output_id = id.clone();
thread::spawn(move || {
let mut buffer = [0_u8; 8192];
let mut output_filter = TerminalOutputFilter::default();
loop {
match reader.read(&mut buffer) {
Ok(0) => break,
Ok(size) => {
let (output, response_count) = output_filter.push(&buffer[..size]);
if response_count > 0 {
if let Ok(mut writer) = output_writer.lock() {
for _ in 0..response_count {
let _ = writer.write_all(PRIMARY_DEVICE_ATTRIBUTES_RESPONSE);
}
let _ = writer.flush();
}
}
if !output.is_empty() {
let data = String::from_utf8_lossy(&output).into_owned();
let _ = output_app.emit(
"terminal-output",
TerminalOutput {
id: output_id.clone(),
data,
},
);
}
}
Err(_) => break,
}
}
});
let exit_app = app.clone();
let exit_id = id.clone();
let sessions = state.sessions.clone();
thread::spawn(move || {
if let Ok(status) = child.wait() {
if let Ok(mut sessions) = sessions.lock() {
sessions.remove(&exit_id);
}
let _ = exit_app.emit(
"terminal-exit",
TerminalExit {
id: exit_id,
code: status.exit_code(),
signal: status.signal().map(ToString::to_string),
},
);
}
});
Ok(())
}
#[tauri::command]
fn write_terminal(state: State<'_, TerminalState>, id: String, data: String) -> Result<(), String> {
let writer = {
let sessions = state.sessions.lock().map_err(|error| error.to_string())?;
sessions
.get(&id)
.map(|session| session.writer.clone())
.ok_or_else(|| format!("terminal session not found: {id}"))?
};
let mut writer = writer.lock().map_err(|error| error.to_string())?;
writer
.write_all(data.as_bytes())
.and_then(|_| writer.flush())
.map_err(|error| error.to_string())
}
#[tauri::command]
fn resize_terminal(
state: State<'_, TerminalState>,
id: String,
cols: u16,
rows: u16,
) -> Result<(), String> {
let sessions = state.sessions.lock().map_err(|error| error.to_string())?;
let session = sessions
.get(&id)
.ok_or_else(|| format!("terminal session not found: {id}"))?;
session
.master
.resize(terminal_size(cols, rows))
.map_err(|error| error.to_string())
}
#[tauri::command]
fn kill_terminal(state: State<'_, TerminalState>, id: String) -> Result<(), String> {
let session = {
let mut sessions = state.sessions.lock().map_err(|error| error.to_string())?;
sessions.remove(&id)
};
if let Some(mut session) = session {
session.killer.kill().map_err(|error| error.to_string())?;
}
Ok(())
}
fn run_git(repo_path: &str, args: &[&str]) -> Result<Vec<u8>, String> {
let output = Command::new("git")
.arg("-C")
.arg(repo_path)
.args(args)
.output()
.map_err(|error| format!("failed to run git: {error}"))?;
if output.status.success() {
return Ok(output.stdout);
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
Err(if stderr.is_empty() {
format!("git exited with status {}", output.status)
} else {
stderr
})
}
fn run_git_allowing_diff(repo_path: &str, args: &[&str]) -> Result<Vec<u8>, String> {
let output = Command::new("git")
.arg("-C")
.arg(repo_path)
.args(args)
.output()
.map_err(|error| format!("failed to run git: {error}"))?;
if output.status.success() || output.status.code() == Some(1) {
return Ok(output.stdout);
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
Err(if stderr.is_empty() {
format!("git exited with status {}", output.status)
} else {
stderr
})
}
fn git_root(repo_path: &str) -> Result<String, String> {
let output = run_git(repo_path, &["rev-parse", "--show-toplevel"])?;
let root_path = String::from_utf8(output)
.map_err(|error| error.to_string())?
.trim()
.to_string();
if root_path.is_empty() {
return Err("selected folder is not a git repository".to_string());
}
Ok(root_path)
}
fn resolve_repo_file_path(root_path: &str, file_path: &str) -> Result<PathBuf, String> {
let trimmed_path = file_path.trim();
if trimmed_path.is_empty() {
return Ok(PathBuf::from(root_path));
}
if Path::new(trimmed_path).is_absolute() || trimmed_path.contains("..") {
return Err("file path must stay inside the repository".to_string());
}
Ok(Path::new(root_path).join(trimmed_path))
}
fn git_branch(root_path: &str) -> Result<String, String> {
let output = run_git(root_path, &["branch", "--show-current"])?;
let branch = String::from_utf8(output)
.map_err(|error| error.to_string())?
.trim()
.to_string();
if !branch.is_empty() {
return Ok(branch);
}
let head = git_head(root_path).unwrap_or_else(|| "unborn".to_string());
Ok(format!("detached @ {head}"))
}
fn git_head(root_path: &str) -> Option<String> {
let output = run_git(root_path, &["rev-parse", "--short", "HEAD"]).ok()?;
let head = String::from_utf8(output).ok()?.trim().to_string();
if head.is_empty() {
None
} else {
Some(head)
}
}
fn git_has_head(root_path: &str) -> bool {
run_git(root_path, &["rev-parse", "--verify", "HEAD"]).is_ok()
}
fn git_ahead_behind(root_path: &str) -> (usize, usize) {
let output = match run_git(
root_path,
&["rev-list", "--left-right", "--count", "@{upstream}...HEAD"],
) {
Ok(output) => output,
Err(_) => return (0, 0),
};
let text = String::from_utf8_lossy(&output);
let mut parts = text.split_whitespace();
let behind = parts
.next()
.and_then(|value| value.parse::<usize>().ok())
.unwrap_or(0);
let ahead = parts
.next()
.and_then(|value| value.parse::<usize>().ok())
.unwrap_or(0);
(ahead, behind)
}
fn git_recent_commits(root_path: &str) -> Vec<GitRecentCommit> {
let output = match run_git(
root_path,
&[
"log",
"-8",
"--date=relative",
"--pretty=format:%h%x1f%s%x1f%an%x1f%cr%x1e",
],
) {
Ok(output) => output,
Err(_) => return Vec::new(),
};
String::from_utf8_lossy(&output)
.split('\x1e')
.filter_map(|record| {
let mut fields = record.trim_matches('\n').split('\x1f');
Some(GitRecentCommit {
hash: fields.next()?.to_string(),
subject: fields.next()?.to_string(),
author: fields.next()?.to_string(),
relative_time: fields.next()?.to_string(),
})
})
.collect()
}
fn classify_git_status(index_status: char, worktree_status: char) -> String {
if index_status == '?' && worktree_status == '?' {
return "untracked".to_string();
}
if index_status == '!' && worktree_status == '!' {
return "ignored".to_string();
}
if index_status == 'R' || worktree_status == 'R' {
return "renamed".to_string();
}
if index_status == 'D' || worktree_status == 'D' {
return "deleted".to_string();
}
if index_status == 'A' || worktree_status == 'A' {
return "added".to_string();
}
"modified".to_string()
}
fn parse_porcelain_status(output: &[u8]) -> Vec<GitStatusEntry> {
let records: Vec<&[u8]> = output
.split(|byte| *byte == 0)
.filter(|record| !record.is_empty())
.collect();
let mut entries = Vec::new();
let mut index = 0;
while index < records.len() {
let record = String::from_utf8_lossy(records[index]);
let bytes = record.as_bytes();
if bytes.len() < 4 {
index += 1;
continue;
}
let index_status = bytes[0] as char;
let worktree_status = bytes[1] as char;
let mut path = record[3..].to_string();
let mut old_path = None;
if (index_status == 'R' || index_status == 'C') && index + 1 < records.len() {
old_path = Some(path);
index += 1;
path = String::from_utf8_lossy(records[index]).to_string();
}
entries.push(GitStatusEntry {
path,
status: classify_git_status(index_status, worktree_status),
index_status: index_status.to_string(),
worktree_status: worktree_status.to_string(),
old_path,
});
index += 1;
}
entries
}
fn summarize_status(entries: &[GitStatusEntry]) -> GitStatusSummary {
GitStatusSummary {
staged: entries
.iter()
.filter(|entry| entry.index_status != " " && entry.index_status != "?")
.count(),
unstaged: entries
.iter()
.filter(|entry| entry.worktree_status != " " && entry.worktree_status != "?")
.count(),
untracked: entries
.iter()
.filter(|entry| entry.index_status == "?" && entry.worktree_status == "?")
.count(),
}
}
#[tauri::command]
fn git_repository_status(repo_path: String) -> Result<GitRepositoryStatus, String> {
let root_path = git_root(&repo_path)?;
let status_output = run_git(
&root_path,
&[
"status",
"--porcelain=v1",
"-z",
"--untracked-files=all",
"--renames",
],
)?;
let entries = parse_porcelain_status(&status_output);
let (ahead, behind) = git_ahead_behind(&root_path);
Ok(GitRepositoryStatus {
branch: git_branch(&root_path)?,
head: git_head(&root_path),
ahead,
behind,
summary: summarize_status(&entries),
recent_commits: git_recent_commits(&root_path),
root_path,
entries,
})
}
fn normalized_paths(paths: Option<Vec<String>>) -> Vec<String> {
paths
.unwrap_or_default()
.into_iter()
.map(|path| path.trim().to_string())
.filter(|path| !path.is_empty())
.collect()
}
fn run_git_with_paths(
root_path: &str,
args_before_pathspec: &[&str],
paths: &[String],
) -> Result<(), String> {
let mut command = Command::new("git");
command.arg("-C").arg(root_path).args(args_before_pathspec);
if !paths.is_empty() {
command.arg("--");
for path in paths {
command.arg(path);
}
}
let output = command
.output()
.map_err(|error| format!("failed to run git: {error}"))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
Err(if stderr.is_empty() {
format!("git exited with status {}", output.status)
} else {
stderr
})
}
#[tauri::command]
fn git_stage_paths(repo_path: String, paths: Option<Vec<String>>) -> Result<(), String> {
let root_path = git_root(&repo_path)?;
let paths = normalized_paths(paths);
if paths.is_empty() {
return run_git_with_paths(&root_path, &["add", "-A"], &paths);
}
run_git_with_paths(&root_path, &["add"], &paths)
}
#[tauri::command]
fn git_unstage_paths(repo_path: String, paths: Option<Vec<String>>) -> Result<(), String> {
let root_path = git_root(&repo_path)?;
let paths = normalized_paths(paths);
run_git_with_paths(&root_path, &["reset", "HEAD"], &paths)
}
#[tauri::command]
fn git_discard_paths(repo_path: String, paths: Vec<String>) -> Result<(), String> {
let root_path = git_root(&repo_path)?;
let paths = normalized_paths(Some(paths));
if paths.is_empty() {
return Err("no paths selected".to_string());
}
run_git_with_paths(&root_path, &["restore", "--staged", "--worktree"], &paths).ok();
run_git_with_paths(&root_path, &["clean", "-fd"], &paths)
}
#[tauri::command]
fn git_commit(repo_path: String, message: String) -> Result<(), String> {
let root_path = git_root(&repo_path)?;
let message = message.trim();
if message.is_empty() {
return Err("commit message is empty".to_string());
}
let output = Command::new("git")
.arg("-C")
.arg(&root_path)
.arg("commit")
.arg("-m")
.arg(message)
.output()
.map_err(|error| format!("failed to run git: {error}"))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
Err(if stderr.is_empty() {
format!("git exited with status {}", output.status)
} else {
stderr
})
}
#[tauri::command]
fn git_open_path(repo_path: String, file_path: String, reveal: bool) -> Result<(), String> {
let root_path = git_root(&repo_path)?;
let target_path = resolve_repo_file_path(&root_path, &file_path)?;
let open_path = if reveal {
if target_path.is_dir() {
target_path
} else {
target_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from(root_path))
}
} else {
target_path
};
Command::new("xdg-open")
.arg(open_path)
.spawn()
.map(|_| ())
.map_err(|error| format!("failed to open path: {error}"))
}
fn list_untracked_paths(root_path: &str, pathspec: Option<&str>) -> Result<Vec<String>, String> {
let output = match pathspec {
Some(pathspec) => run_git(
root_path,
&[
"ls-files",
"--others",
"--exclude-standard",
"-z",
"--",
pathspec,
],
)?,
None => run_git(
root_path,
&["ls-files", "--others", "--exclude-standard", "-z"],
)?,
};
Ok(output
.split(|byte| *byte == 0)
.filter(|record| !record.is_empty())
.map(|record| String::from_utf8_lossy(record).to_string())
.collect())
}
fn append_untracked_diff(root_path: &str, path: &str, output: &mut Vec<u8>) -> Result<(), String> {
let absolute_path = Path::new(root_path).join(path);
let absolute_path = absolute_path
.to_str()
.ok_or_else(|| "file path is not valid UTF-8".to_string())?;
let mut diff = run_git_allowing_diff(
root_path,
&[
"diff",
"--no-index",
"--no-ext-diff",
"--no-color",
"--",
"/dev/null",
absolute_path,
],
)?;
if !output.is_empty() && !output.ends_with(b"\n") {
output.push(b'\n');
}
output.append(&mut diff);
Ok(())
}
fn append_bytes(output: &mut Vec<u8>, mut next: Vec<u8>) {
if next.is_empty() {
return;
}
if !output.is_empty() && !output.ends_with(b"\n") {
output.push(b'\n');
}
output.append(&mut next);
}
#[tauri::command]
fn git_diff_file(repo_path: String, file_path: Option<String>) -> Result<String, String> {
let root_path = git_root(&repo_path)?;
let normalized_path = file_path
.as_deref()
.map(str::trim)
.filter(|path| !path.is_empty());
let untracked_paths = list_untracked_paths(&root_path, normalized_path)?;
let has_head = git_has_head(&root_path);
let mut diff_output = Vec::new();
match (has_head, normalized_path) {
(_, Some(path)) if untracked_paths.iter().any(|untracked| untracked == path) => {}
(true, Some(path)) => append_bytes(
&mut diff_output,
run_git_allowing_diff(
&root_path,
&[
"diff",
"--no-ext-diff",
"--no-color",
"--find-renames",
"HEAD",
"--",
path,
],
)?,
),
(true, None) => append_bytes(
&mut diff_output,
run_git_allowing_diff(
&root_path,
&[
"diff",
"--no-ext-diff",
"--no-color",
"--find-renames",
"HEAD",
],
)?,
),
(false, Some(path)) => {
append_bytes(
&mut diff_output,
run_git_allowing_diff(
&root_path,
&[
"diff",
"--cached",
"--no-ext-diff",
"--no-color",
"--",
path,
],
)?,
);
append_bytes(
&mut diff_output,
run_git_allowing_diff(
&root_path,
&["diff", "--no-ext-diff", "--no-color", "--", path],
)?,
);
}
(false, None) => {
append_bytes(
&mut diff_output,
run_git_allowing_diff(
&root_path,
&["diff", "--cached", "--no-ext-diff", "--no-color"],
)?,
);
append_bytes(
&mut diff_output,
run_git_allowing_diff(&root_path, &["diff", "--no-ext-diff", "--no-color"])?,
);
}
}
for untracked_path in untracked_paths {
append_untracked_diff(&root_path, &untracked_path, &mut diff_output)?;
}
String::from_utf8(diff_output).map_err(|error| error.to_string())
}
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
#[tauri::command]
fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name)
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.manage(TerminalState::default())
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![
greet,
spawn_terminal,
write_terminal,
resize_terminal,
kill_terminal,
git_repository_status,
git_diff_file,
git_stage_paths,
git_unstage_paths,
git_discard_paths,
git_commit,
git_open_path
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
#[cfg(test)]
mod tests {
use super::TerminalOutputFilter;
#[test]
fn terminal_output_filter_answers_primary_device_attributes() {
let mut filter = TerminalOutputFilter::default();
let (output, response_count) = filter.push(b"hello\x1b[cworld");
assert_eq!(output, b"helloworld");
assert_eq!(response_count, 1);
}
#[test]
fn terminal_output_filter_handles_split_primary_device_attributes() {
let mut filter = TerminalOutputFilter::default();
let (first_output, first_response_count) = filter.push(b"hello\x1b[");
let (second_output, second_response_count) = filter.push(b"cworld");
assert_eq!(first_output, b"hello");
assert_eq!(first_response_count, 0);
assert_eq!(second_output, b"world");
assert_eq!(second_response_count, 1);
}
#[test]
fn terminal_output_filter_preserves_non_query_escape_sequences() {
let mut filter = TerminalOutputFilter::default();
let (first_output, first_response_count) = filter.push(b"\x1b[");
let (second_output, second_response_count) = filter.push(b"31mred");
assert!(first_output.is_empty());
assert_eq!(first_response_count, 0);
assert_eq!(second_output, b"\x1b[31mred");
assert_eq!(second_response_count, 0);
}
}

6
src-tauri/src/main.rs Normal file
View file

@ -0,0 +1,6 @@
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
githikari_lib::run()
}

35
src-tauri/tauri.conf.json Normal file
View file

@ -0,0 +1,35 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "githikari",
"version": "0.1.0",
"identifier": "ng.imnya.githikari",
"build": {
"beforeDevCommand": "bun run dev",
"devUrl": "http://localhost:1420",
"beforeBuildCommand": "bun run build",
"frontendDist": "../dist"
},
"app": {
"windows": [
{
"title": "wow",
"width": 1600,
"height": 900
}
],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
]
}
}

1
src/assets/react.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4 KiB

63
src/components/diff.tsx Normal file
View file

@ -0,0 +1,63 @@
import { parsePatchFiles } from "@pierre/diffs";
import { FileDiff, Virtualizer } from "@pierre/diffs/react";
import { GitCompare } from "lucide-react";
import { useMemo } from "react";
import { cn } from "#/lib/utils";
type MyPatchDiffProps = {
className?: string;
emptyMessage?: string;
patch?: string;
};
export default function MyPatchDiff({
className,
emptyMessage = "Open a repository and select a changed file.",
patch = "",
}: MyPatchDiffProps) {
const files = useMemo(() => {
if (patch.trim().length === 0) {
return [];
}
return parsePatchFiles(patch, "git-client-diff", false).flatMap(
(diff) => diff.files,
);
}, [patch]);
if (patch.trim().length === 0) {
return (
<div
className={cn(
"flex h-full min-h-0 w-full flex-col items-center justify-center gap-3 bg-background p-8 text-center text-muted-foreground",
className,
)}
>
<GitCompare className="size-8 opacity-70" />
<p className="max-w-sm text-sm">{emptyMessage}</p>
</div>
);
}
return (
<Virtualizer
className={cn(
"h-full min-h-0 w-full overflow-auto bg-background",
className,
)}
contentClassName="min-w-full space-y-2 py-2"
>
{files.map((fileDiff, index) => (
<FileDiff
className="min-w-full"
fileDiff={fileDiff}
key={fileDiff.cacheKey ?? `${fileDiff.name}-${index}`}
options={{
theme: { dark: "pierre-dark", light: "pierre-light" },
diffStyle: "unified",
}}
/>
))}
</Virtualizer>
);
}

View file

@ -0,0 +1,968 @@
import {
Check,
Circle,
Clock3,
Files,
FileWarning,
FolderOpen,
GitBranch,
GitCommitHorizontal,
Minus,
Plus,
RefreshCw,
RotateCcw,
} from "lucide-react";
import {
type FormEvent,
type ReactNode,
useEffect,
useMemo,
useState,
} from "react";
import MyPatchDiff from "#/components/diff";
import { Sidebar } from "#/components/sidebar";
import { Terminal } from "#/components/terminal";
import { Button } from "#/components/ui/button";
import {
commitChanges,
discardPaths,
type GitRepositoryStatus,
type GitStatusEntry,
loadFileDiff,
loadRepositoryStatus,
openRepositoryFile,
openRepositoryFolder,
revealRepositoryFile,
stagePaths,
toTreeGitStatus,
unstagePaths,
} from "#/lib/git";
import { cn } from "#/lib/utils";
type LoadState = "idle" | "loading" | "ready" | "error";
function basename(path: string) {
return path.split(/[\\/]/).filter(Boolean).at(-1) ?? path;
}
function dirname(path: string) {
const parts = path.split("/");
return parts.length > 1 ? parts.slice(0, -1).join("/") : "";
}
function pathContainsChange(
path: string | null,
entries: readonly GitStatusEntry[],
) {
if (path == null) {
return true;
}
return entries.some(
(entry) => entry.path === path || entry.path.startsWith(`${path}/`),
);
}
function scopeEntries(path: string | null, entries: readonly GitStatusEntry[]) {
if (path == null) {
return entries;
}
return entries.filter(
(entry) => entry.path === path || entry.path.startsWith(`${path}/`),
);
}
function scopePaths(path: string, entries: readonly GitStatusEntry[]) {
return scopeEntries(path, entries).map((entry) => entry.path);
}
function hasIndexChange(entry: GitStatusEntry) {
return entry.indexStatus !== " " && entry.indexStatus !== "?";
}
function hasWorktreeChange(entry: GitStatusEntry) {
return entry.worktreeStatus !== " " || entry.status === "untracked";
}
function statusLabel(entry: GitStatusEntry) {
if (entry.status === "untracked") {
return "Untracked";
}
if (entry.indexStatus !== " " && entry.worktreeStatus !== " ") {
return "Staged + modified";
}
if (entry.indexStatus !== " ") {
return "Staged";
}
return entry.status[0].toUpperCase() + entry.status.slice(1);
}
function StatusBadge({ entry }: { entry: GitStatusEntry }) {
return (
<span className="inline-flex h-5 shrink-0 items-center rounded border border-border bg-muted px-1.5 text-[11px] text-muted-foreground">
{statusLabel(entry)}
</span>
);
}
function SummaryPill({ label, value }: { label: string; value: number }) {
return (
<span className="inline-flex min-w-0 items-center gap-1 rounded border border-border bg-background px-1.5 py-0.5 text-[11px] text-muted-foreground">
<span className="truncate">{label}</span>
<strong className="text-foreground">{value}</strong>
</span>
);
}
function ActionButton({
busy,
children,
disabled,
icon,
onClick,
title,
variant = "outline",
}: {
busy?: boolean;
children: string;
disabled?: boolean;
icon: ReactNode;
onClick: () => void;
title: string;
variant?: "default" | "outline" | "secondary" | "ghost" | "destructive";
}) {
return (
<Button
aria-label={title}
className="min-w-0"
disabled={disabled || busy}
onClick={onClick}
size="sm"
title={title}
type="button"
variant={variant}
>
{busy ? <RefreshCw className="size-3.5 animate-spin" /> : icon}
<span className="truncate">{children}</span>
</Button>
);
}
function ContextMenuButton({
children,
disabled,
onClick,
variant = "default",
}: {
children: ReactNode;
disabled?: boolean;
onClick: () => void;
variant?: "default" | "destructive";
}) {
return (
<button
className={cn(
"flex h-7 w-full items-center rounded px-2 text-left text-xs outline-none hover:bg-muted focus-visible:bg-muted disabled:pointer-events-none disabled:opacity-40",
variant === "destructive" && "text-destructive hover:bg-destructive/10",
)}
disabled={disabled}
onClick={onClick}
type="button"
>
{children}
</button>
);
}
export function GitClient() {
const [repoPath, setRepoPath] = useState<string | null>(null);
const [status, setStatus] = useState<GitRepositoryStatus | null>(null);
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const [patch, setPatch] = useState("");
const [commitMessage, setCommitMessage] = useState("");
const [loadState, setLoadState] = useState<LoadState>("idle");
const [diffLoading, setDiffLoading] = useState(false);
const [actionPending, setActionPending] = useState<string | null>(null);
const [diffRevision, setDiffRevision] = useState(0);
const [error, setError] = useState<string | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const changedPaths = useMemo(
() => status?.entries.map((entry) => entry.path) ?? [],
[status],
);
const gitStatus = useMemo(
() => (status ? toTreeGitStatus(status.entries) : undefined),
[status],
);
const selectedEntries = useMemo(
() => scopeEntries(selectedPath, status?.entries ?? []),
[selectedPath, status],
);
const selectedExactEntry = useMemo(
() => status?.entries.find((entry) => entry.path === selectedPath) ?? null,
[selectedPath, status],
);
const selectedScopePaths = useMemo(
() => selectedEntries.map((entry) => entry.path),
[selectedEntries],
);
const selectedLabel =
selectedPath == null
? "All changes"
: selectedEntries.length > 1
? `${selectedPath} (${selectedEntries.length} files)`
: selectedPath;
const hasRepository = repoPath != null;
const hasChanges = (status?.entries.length ?? 0) > 0;
const hasSelectedChanges = selectedEntries.length > 0;
const hasStagedChanges = (status?.summary.staged ?? 0) > 0;
const currentDirectory = selectedPath ? dirname(selectedPath) : "";
const refreshStatus = async (path = repoPath) => {
if (path == null) {
return;
}
setLoadState("loading");
setError(null);
try {
const nextStatus = await loadRepositoryStatus(path);
setRepoPath(nextStatus.rootPath);
setStatus(nextStatus);
setSelectedPath((current) =>
pathContainsChange(current, nextStatus.entries) ? current : null,
);
setLoadState("ready");
setDiffRevision((revision) => revision + 1);
} catch (nextError) {
setError(
nextError instanceof Error ? nextError.message : String(nextError),
);
setLoadState("error");
}
};
const openRepository = async () => {
setError(null);
setNotice(null);
try {
const selected = await openRepositoryFolder();
if (selected == null) {
return;
}
setRepoPath(selected);
setPatch("");
setSelectedPath(null);
await refreshStatus(selected);
} catch (nextError) {
setError(
nextError instanceof Error ? nextError.message : String(nextError),
);
setLoadState("error");
}
};
const runGitAction = async (
key: string,
action: () => Promise<void>,
successMessage: string,
) => {
if (repoPath == null) {
return;
}
setActionPending(key);
setError(null);
setNotice(null);
try {
await action();
setNotice(successMessage);
await refreshStatus(repoPath);
} catch (nextError) {
setError(
nextError instanceof Error ? nextError.message : String(nextError),
);
} finally {
setActionPending(null);
}
};
const runPathAction = async (
key: string,
path: string,
action: (paths: readonly string[]) => Promise<void>,
successMessage: string,
) => {
if (repoPath == null) {
return;
}
const paths = scopePaths(path, status?.entries ?? []);
if (paths.length === 0) {
return;
}
await runGitAction(key, () => action(paths), successMessage);
};
const stageSelection = () => {
if (repoPath == null || !hasSelectedChanges) {
return;
}
runGitAction(
"stage-selection",
() =>
stagePaths(
repoPath,
selectedPath == null ? undefined : selectedScopePaths,
),
selectedPath == null ? "Staged all changes." : "Staged selection.",
);
};
const unstageSelection = () => {
if (repoPath == null || !hasSelectedChanges) {
return;
}
runGitAction(
"unstage-selection",
() =>
unstagePaths(
repoPath,
selectedPath == null ? undefined : selectedScopePaths,
),
selectedPath == null ? "Unstaged all changes." : "Unstaged selection.",
);
};
const discardSelection = () => {
if (repoPath == null || selectedPath == null || !hasSelectedChanges) {
return;
}
const confirmed = window.confirm(
`Discard local changes in ${selectedPath}? This cannot be undone.`,
);
if (!confirmed) {
return;
}
runGitAction(
"discard-selection",
() => discardPaths(repoPath, selectedScopePaths),
"Discarded selection.",
);
};
const stagePath = (path: string) =>
runPathAction(
`stage:${path}`,
path,
(paths) => stagePaths(repoPath ?? "", paths),
"Staged selection.",
);
const unstagePath = (path: string) =>
runPathAction(
`unstage:${path}`,
path,
(paths) => unstagePaths(repoPath ?? "", paths),
"Unstaged selection.",
);
const discardPath = (path: string) => {
if (repoPath == null) {
return;
}
const paths = scopePaths(path, status?.entries ?? []);
if (paths.length === 0) {
return;
}
const confirmed = window.confirm(
`Discard local changes in ${path}? This cannot be undone.`,
);
if (!confirmed) {
return;
}
runGitAction(
`discard:${path}`,
() => discardPaths(repoPath, paths),
"Discarded selection.",
);
};
const openPath = async (path: string) => {
if (repoPath == null) {
return;
}
setError(null);
try {
await openRepositoryFile(repoPath, path);
} catch (nextError) {
setError(
nextError instanceof Error ? nextError.message : String(nextError),
);
}
};
const revealPath = async (path: string) => {
if (repoPath == null) {
return;
}
setError(null);
try {
await revealRepositoryFile(repoPath, path);
} catch (nextError) {
setError(
nextError instanceof Error ? nextError.message : String(nextError),
);
}
};
const commitStagedChanges = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (repoPath == null || commitMessage.trim().length === 0) {
return;
}
runGitAction(
"commit",
async () => {
await commitChanges(repoPath, commitMessage);
setCommitMessage("");
},
"Committed staged changes.",
);
};
const renderRowDecoration = ({ item }: { item: { path: string } }) => {
const entries = scopeEntries(item.path, status?.entries ?? []);
if (entries.length === 0) {
return null;
}
const hasStaged = entries.some(hasIndexChange);
const hasUnstaged = entries.some(hasWorktreeChange);
const hasUntracked = entries.some((entry) => entry.status === "untracked");
if (hasStaged && hasUnstaged) {
return { text: "±", title: "Partly staged" };
}
if (hasStaged) {
return { text: "✓", title: "Staged" };
}
if (hasUntracked) {
return { text: "+", title: "Untracked" };
}
return { text: "○", title: "Unstaged" };
};
const renderContextMenu = (
item: { kind: "directory" | "file"; path: string },
context: {
anchorRect: { bottom: number; left: number };
close: (options?: { restoreFocus?: boolean }) => void;
},
) => {
const entries = scopeEntries(item.path, status?.entries ?? []);
const hasPathChanges = entries.length > 0;
const hasStaged = entries.some(hasIndexChange);
const hasUnstaged = entries.some(hasWorktreeChange);
const busy =
actionPending === `stage:${item.path}` ||
actionPending === `unstage:${item.path}` ||
actionPending === `discard:${item.path}`;
const runAndClose = (action: () => void | Promise<void>) => {
context.close({ restoreFocus: false });
void action();
};
return (
<div
className="z-50 min-w-44 rounded-md border bg-popover p-1 text-popover-foreground shadow-lg"
data-file-tree-context-menu-root="true"
style={{
left: context.anchorRect.left,
position: "fixed",
top: context.anchorRect.bottom + 4,
}}
>
<ContextMenuButton
onClick={() => runAndClose(() => openPath(item.path))}
>
Open {item.kind === "directory" ? "folder" : "file"}
</ContextMenuButton>
<ContextMenuButton
onClick={() => runAndClose(() => revealPath(item.path))}
>
Reveal in folder
</ContextMenuButton>
<div className="my-1 h-px bg-border" />
<ContextMenuButton
disabled={!hasPathChanges || !hasUnstaged || busy}
onClick={() => runAndClose(() => stagePath(item.path))}
>
Stage
</ContextMenuButton>
<ContextMenuButton
disabled={!hasPathChanges || !hasStaged || busy}
onClick={() => runAndClose(() => unstagePath(item.path))}
>
Unstage
</ContextMenuButton>
<ContextMenuButton
disabled={!hasPathChanges || busy}
onClick={() => runAndClose(() => discardPath(item.path))}
variant="destructive"
>
Discard changes
</ContextMenuButton>
</div>
);
};
useEffect(() => {
void diffRevision;
if (repoPath == null) {
setPatch("");
return;
}
let cancelled = false;
async function loadSelectedDiff() {
setDiffLoading(true);
setPatch("");
setError(null);
try {
const nextPatch = await loadFileDiff(repoPath, selectedPath);
if (!cancelled) {
setPatch(nextPatch);
}
} catch (nextError) {
if (!cancelled) {
setPatch("");
setError(
nextError instanceof Error ? nextError.message : String(nextError),
);
}
} finally {
if (!cancelled) {
setDiffLoading(false);
}
}
}
loadSelectedDiff();
return () => {
cancelled = true;
};
}, [repoPath, selectedPath, diffRevision]);
const sidebarHeader = (
<div className="border-b bg-background px-2 py-2">
<div className="flex items-center gap-1">
<Button
className="min-w-0 flex-1 justify-start"
onClick={openRepository}
size="sm"
type="button"
variant="outline"
>
<FolderOpen className="size-3.5" />
<span className="truncate">
{status?.rootPath ? basename(status.rootPath) : "Open repository"}
</span>
</Button>
<Button
aria-label="Show all changes"
disabled={!hasRepository || loadState === "loading"}
onClick={() => setSelectedPath(null)}
size="icon-sm"
type="button"
variant={selectedPath == null ? "secondary" : "ghost"}
>
<Files className="size-3.5" />
</Button>
<Button
aria-label="Refresh repository"
disabled={!hasRepository || loadState === "loading"}
onClick={() => refreshStatus()}
size="icon-sm"
type="button"
variant="ghost"
>
<RefreshCw
className={cn(
"size-3.5",
loadState === "loading" && "animate-spin",
)}
/>
</Button>
</div>
{status ? (
<div className="mt-2 space-y-2">
<div className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
<GitBranch className="size-3.5 shrink-0" />
<span className="truncate font-medium text-foreground">
{status.branch}
</span>
{status.head ? (
<span className="shrink-0">{status.head}</span>
) : null}
</div>
<div className="flex flex-wrap gap-1">
<SummaryPill label="staged" value={status.summary.staged} />
<SummaryPill label="changed" value={status.summary.unstaged} />
<SummaryPill label="new" value={status.summary.untracked} />
</div>
<p className="truncate text-[11px] text-muted-foreground">
{status.rootPath}
</p>
</div>
) : null}
</div>
);
const sidebarFooter = (
<div className="border-t bg-background">
<div className="space-y-2 border-b p-2">
<div className="flex items-center justify-between gap-2">
<p className="min-w-0 truncate text-xs font-medium">
{selectedPath == null ? "All changes" : basename(selectedPath)}
</p>
{selectedExactEntry ? (
<StatusBadge entry={selectedExactEntry} />
) : null}
</div>
<div className="grid grid-cols-3 gap-1">
<Button
aria-label="Stage selected changes"
disabled={
!hasRepository ||
!hasSelectedChanges ||
actionPending === "stage-selection"
}
onClick={stageSelection}
size="sm"
title="Stage selected changes"
type="button"
variant="outline"
>
<Plus className="size-3.5" />
Stage
</Button>
<Button
aria-label="Unstage selected changes"
disabled={
!hasRepository ||
!hasSelectedChanges ||
actionPending === "unstage-selection"
}
onClick={unstageSelection}
size="sm"
title="Unstage selected changes"
type="button"
variant="outline"
>
<Minus className="size-3.5" />
Unstage
</Button>
<Button
aria-label="Discard selected changes"
disabled={
!hasRepository ||
selectedPath == null ||
!hasSelectedChanges ||
actionPending === "discard-selection"
}
onClick={discardSelection}
size="sm"
title="Discard selected changes"
type="button"
variant="destructive"
>
<RotateCcw className="size-3.5" />
Discard
</Button>
</div>
</div>
<form className="p-2" onSubmit={commitStagedChanges}>
<textarea
className="min-h-20 w-full resize-none rounded-md border border-input bg-background px-2 py-1.5 text-sm outline-none transition-colors placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30"
disabled={!hasRepository || actionPending === "commit"}
onChange={(event) => setCommitMessage(event.target.value)}
placeholder="Commit message"
value={commitMessage}
/>
<div className="mt-2 flex items-center gap-2">
<p className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{status ? `${status.summary.staged} staged files` : "No repository"}
</p>
<Button
disabled={
!hasRepository ||
!hasStagedChanges ||
commitMessage.trim().length === 0 ||
actionPending === "commit"
}
size="sm"
type="submit"
>
{actionPending === "commit" ? (
<RefreshCw className="size-3.5 animate-spin" />
) : (
<Check className="size-3.5" />
)}
Commit
</Button>
</div>
</form>
</div>
);
return (
<div className="flex min-h-0 w-full flex-1">
<Sidebar
// footer={sidebarFooter}
gitStatus={gitStatus}
header={sidebarHeader}
initialExpansion={2}
onActivePathChange={setSelectedPath}
onOpenFile={setSelectedPath}
onSelectionChange={(paths) => setSelectedPath(paths.at(-1) ?? null)}
paths={changedPaths}
renderContextMenu={renderContextMenu}
renderRowDecoration={renderRowDecoration}
selectedPaths={selectedPath ? [selectedPath] : []}
/>
<section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<section className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
<header className="shrink-0 border-b bg-background">
<div className="flex min-h-12 items-center gap-2 px-3 py-2">
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<p className="min-w-0 truncate text-sm font-semibold">
{selectedLabel}
</p>
{selectedExactEntry ? (
<StatusBadge entry={selectedExactEntry} />
) : null}
</div>
<div className="mt-1 flex min-w-0 items-center gap-2 text-xs text-muted-foreground">
{status ? (
<>
<span className="inline-flex min-w-0 items-center gap-1">
<GitBranch className="size-3.5 shrink-0" />
<span className="truncate">{status.branch}</span>
</span>
{status.ahead > 0 ? (
<span>ahead {status.ahead}</span>
) : null}
{status.behind > 0 ? (
<span>behind {status.behind}</span>
) : null}
</>
) : (
<span>Open a repository to start reviewing changes.</span>
)}
{diffLoading ? <span>Loading diff...</span> : null}
</div>
</div>
<div className="flex shrink-0 items-center gap-1">
<ActionButton
busy={actionPending === "stage-selection"}
disabled={!hasRepository || !hasSelectedChanges}
icon={<Plus className="size-3.5" />}
onClick={stageSelection}
title="Stage current selection"
>
Stage
</ActionButton>
<ActionButton
busy={actionPending === "unstage-selection"}
disabled={!hasRepository || !hasSelectedChanges}
icon={<Minus className="size-3.5" />}
onClick={unstageSelection}
title="Unstage current selection"
>
Unstage
</ActionButton>
<Button
aria-label="Discard current selection"
disabled={
!hasRepository ||
selectedPath == null ||
!hasSelectedChanges ||
actionPending === "discard-selection"
}
onClick={discardSelection}
size="icon-sm"
title="Discard current selection"
type="button"
variant="destructive"
>
{actionPending === "discard-selection" ? (
<RefreshCw className="size-3.5 animate-spin" />
) : (
<RotateCcw className="size-3.5" />
)}
</Button>
</div>
</div>
{hasRepository ? (
<div className="flex min-h-9 items-center gap-2 border-t bg-muted/30 px-3 py-1.5">
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto">
<SummaryPill
label="changed"
value={status?.entries.length ?? 0}
/>
<SummaryPill
label="staged"
value={status?.summary.staged ?? 0}
/>
<SummaryPill
label="unstaged"
value={status?.summary.unstaged ?? 0}
/>
{currentDirectory ? (
<span className="truncate text-xs text-muted-foreground">
in {currentDirectory}
</span>
) : null}
</div>
<Button
disabled={!hasChanges || actionPending === "stage-selection"}
onClick={() =>
runGitAction(
"stage-selection",
() => stagePaths(repoPath),
"Staged all changes.",
)
}
size="xs"
type="button"
variant="outline"
>
<Plus className="size-3" />
Stage all
</Button>
<Button
disabled={
!hasStagedChanges || actionPending === "unstage-selection"
}
onClick={() =>
runGitAction(
"unstage-selection",
() => unstagePaths(repoPath),
"Unstaged all changes.",
)
}
size="xs"
type="button"
variant="outline"
>
<Minus className="size-3" />
Unstage all
</Button>
</div>
) : null}
</header>
{error ? (
<div className="flex shrink-0 items-center gap-2 border-b bg-destructive/10 px-3 py-2 text-sm text-destructive">
<FileWarning className="size-4 shrink-0" />
<p className="min-w-0 flex-1 truncate">{error}</p>
</div>
) : null}
{notice ? (
<div className="flex shrink-0 items-center gap-2 border-b bg-muted/60 px-3 py-2 text-sm text-muted-foreground">
<Circle className="size-3 shrink-0 fill-current" />
<p className="min-w-0 flex-1 truncate">{notice}</p>
</div>
) : null}
<div className="min-h-0 flex-1 overflow-hidden">
<MyPatchDiff
emptyMessage={
loadState === "idle"
? "Open a Git repository to see changed files and diffs."
: status?.entries.length === 0
? "Working tree is clean."
: "No diff for this selection."
}
patch={patch}
/>
</div>
{status?.recentCommits.length ? (
<div className="flex h-24 shrink-0 items-stretch gap-3 overflow-x-auto border-t bg-background px-3 py-2">
<div className="flex w-28 shrink-0 items-center gap-1 text-xs font-medium text-muted-foreground">
<Clock3 className="size-3.5" />
History
</div>
{status.recentCommits.map((commit) => (
<div
className="flex w-64 shrink-0 flex-col justify-center border-l pl-3"
key={commit.hash}
>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<GitCommitHorizontal className="size-3.5" />
<span>{commit.hash}</span>
<span className="truncate">{commit.relativeTime}</span>
</div>
<p className="mt-1 truncate text-sm font-medium">
{commit.subject}
</p>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{commit.author}
</p>
</div>
))}
</div>
) : null}
</section>
<Terminal />
</section>
</div>
);
}
export default GitClient;

42
src/components/header.tsx Normal file
View file

@ -0,0 +1,42 @@
import {
Menubar,
MenubarContent,
MenubarGroup,
MenubarItem,
MenubarMenu,
MenubarSeparator,
MenubarTrigger,
} from "@/components/ui/menubar";
import { Link } from "@tanstack/react-router";
export default function Header() {
return (
<header className="flex flex-row px-2 mb-2">
<Menubar className="border-0">
<MenubarMenu>
<MenubarTrigger>
<img src="/logo.svg" alt="Logo" width="16" height="16" />
</MenubarTrigger>
<MenubarContent>
<MenubarGroup>
<MenubarItem>New repository...</MenubarItem>
</MenubarGroup>
<MenubarSeparator />
<MenubarGroup>
<MenubarItem>Add local repository...</MenubarItem>
<MenubarItem>Clone repository...</MenubarItem>
</MenubarGroup>
<MenubarSeparator />
<MenubarGroup>
<MenubarItem asChild><Link to="/settings">Settings</Link></MenubarItem>
</MenubarGroup>
<MenubarSeparator />
<MenubarGroup>
<MenubarItem>Exit</MenubarItem>
</MenubarGroup>
</MenubarContent>
</MenubarMenu>
</Menubar>
</header>
);
}

View file

@ -0,0 +1,234 @@
import type {
ContextMenuItem,
ContextMenuOpenContext,
FileTreeDensity,
FileTreeInitialExpansion,
FileTreeRowDecorationRenderer,
FileTreeSearchMode,
GitStatusEntry,
} from "@pierre/trees";
import { FileTree as PierreFileTree, useFileTree } from "@pierre/trees/react";
import type {
CSSProperties,
KeyboardEvent,
MouseEvent,
ReactNode,
} from "react";
import { useEffect, useMemo, useRef } from "react";
import { cn } from "#/lib/utils";
export type FileTreeProps = {
className?: string;
density?: FileTreeDensity;
footer?: ReactNode;
gitStatus?: readonly GitStatusEntry[];
header?: ReactNode;
initialExpansion?: FileTreeInitialExpansion;
initialSelectedPaths?: readonly string[];
onOpenFile?: (path: string) => void;
onActivePathChange?: (path: string) => void;
onSelectionChange?: (selectedPaths: readonly string[]) => void;
paths?: readonly string[];
renderContextMenu?: (
item: ContextMenuItem,
context: ContextMenuOpenContext,
) => ReactNode;
renderRowDecoration?: FileTreeRowDecorationRenderer;
search?: boolean;
searchMode?: FileTreeSearchMode;
selectedPaths?: readonly string[];
style?: CSSProperties;
};
const DEFAULT_PATHS = [
"src/routes/index.tsx",
"src/components/diff.tsx",
"src/components/sidebar/index.tsx",
"src/components/sidebar/fileTree.tsx",
"src/components/terminal/base.tsx",
"src/components/terminal/tab.tsx",
"src/styles.css",
"src-tauri/tauri.conf.json",
"package.json",
] as const;
const TREE_CSS = `
button[data-type='item'] {
border-radius: 6px;
}
button[data-type='item'][data-item-selected] {
font-weight: 600;
}
button[data-type='item'][data-item-focused] {
outline-offset: -2px;
}
`;
function findEventItemPath(event: MouseEvent<HTMLElement>) {
for (const item of event.nativeEvent.composedPath()) {
if (item instanceof HTMLElement && item.dataset.itemPath != null) {
return item.dataset.itemPath;
}
}
return null;
}
function useLatestRef<TValue>(value: TValue) {
const ref = useRef(value);
useEffect(() => {
ref.current = value;
}, [value]);
return ref;
}
export function FileTree({
className,
density = "compact",
footer,
gitStatus,
header,
initialExpansion = 1,
initialSelectedPaths,
onActivePathChange,
onOpenFile,
onSelectionChange,
paths = DEFAULT_PATHS,
renderContextMenu,
renderRowDecoration,
search = true,
searchMode = "expand-matches",
selectedPaths,
style,
}: FileTreeProps) {
const didMountPathsRef = useRef(false);
const onActivePathChangeRef = useLatestRef(onActivePathChange);
const onSelectionChangeRef = useLatestRef(onSelectionChange);
const renderRowDecorationRef = useLatestRef(renderRowDecoration);
const normalizedPaths = useMemo(
() => [...paths].filter((path) => path.trim().length > 0),
[paths],
);
const { model } = useFileTree({
density,
fileTreeSearchMode: searchMode,
flattenEmptyDirectories: true,
gitStatus,
initialExpansion,
initialSelectedPaths,
paths: normalizedPaths,
search,
stickyFolders: true,
unsafeCSS: TREE_CSS,
onSelectionChange: (nextSelectedPaths) => {
onSelectionChangeRef.current?.(nextSelectedPaths);
},
renderRowDecoration: (context) =>
renderRowDecorationRef.current?.(context) ?? null,
});
useEffect(() => {
if (!didMountPathsRef.current) {
didMountPathsRef.current = true;
return;
}
model.resetPaths(normalizedPaths);
}, [model, normalizedPaths]);
useEffect(() => {
model.setGitStatus(gitStatus);
}, [model, gitStatus]);
useEffect(() => {
if (selectedPaths == null) {
return;
}
const nextSelectedPaths = new Set(selectedPaths);
for (const path of model.getSelectedPaths()) {
if (!nextSelectedPaths.has(path)) {
model.getItem(path)?.deselect();
}
}
for (const path of selectedPaths) {
model.getItem(path)?.select();
}
}, [model, selectedPaths]);
const activatePath = (path: string | null) => {
if (path == null) {
return;
}
onActivePathChangeRef.current?.(path);
onSelectionChangeRef.current?.([path]);
};
const openPath = (path: string | null) => {
if (path == null) {
return;
}
activatePath(path);
const item = model.getItem(path);
if (item == null || item.isDirectory()) {
return;
}
onOpenFile?.(path);
};
const handleClick = (event: MouseEvent<HTMLElement>) => {
activatePath(findEventItemPath(event));
};
const handleDoubleClick = (event: MouseEvent<HTMLElement>) => {
openPath(findEventItemPath(event));
};
const handleKeyDown = (event: KeyboardEvent<HTMLElement>) => {
if (event.key !== "Enter") {
return;
}
activatePath(model.getFocusedPath());
openPath(model.getFocusedPath());
};
const treeStyle = {
"--trees-bg-override": "#fcf8f9",
"--trees-fg-override": "#191017",
"--trees-border-color-override": "#e7dfe4",
"--trees-selected-bg-override": "#f1d9e3",
"--trees-focus-ring-color-override": "#9d5670",
...style,
} as CSSProperties;
return (
<div className={cn("flex h-full min-h-0 w-full flex-col", className)}>
{header}
<PierreFileTree
className="min-h-0 flex-1"
model={model}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
onKeyDown={handleKeyDown}
renderContextMenu={renderContextMenu}
style={treeStyle}
/>
{footer}
</div>
);
}
export default FileTree;

View file

@ -0,0 +1,117 @@
import { type PointerEvent, useCallback, useState } from "react";
import { cn } from "#/lib/utils";
import FileTree, { type FileTreeProps } from "./fileTree";
type SidebarProps = Pick<
FileTreeProps,
| "footer"
| "gitStatus"
| "header"
| "initialExpansion"
| "onActivePathChange"
| "onOpenFile"
| "onSelectionChange"
| "paths"
| "renderContextMenu"
| "renderRowDecoration"
| "selectedPaths"
> & {
className?: string;
defaultWidth?: number;
maxWidth?: number;
minEditorWidth?: number;
minWidth?: number;
};
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
export function Sidebar({
className,
defaultWidth = 260,
footer,
gitStatus,
header,
initialExpansion,
maxWidth = 520,
minEditorWidth = 320,
minWidth = 180,
onActivePathChange,
onOpenFile,
onSelectionChange,
paths,
renderContextMenu,
renderRowDecoration,
selectedPaths,
}: SidebarProps) {
const [width, setWidth] = useState(defaultWidth);
const handleResizeStart = useCallback(
(event: PointerEvent<HTMLHRElement>) => {
const containerWidth =
event.currentTarget.parentElement?.getBoundingClientRect().width ??
window.innerWidth;
const resizeMaxWidth = Math.min(
maxWidth,
containerWidth - minEditorWidth,
);
const previousUserSelect = document.body.style.userSelect;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
document.body.style.userSelect = "none";
const handleMove = (moveEvent: globalThis.PointerEvent) => {
setWidth(clamp(moveEvent.clientX, minWidth, resizeMaxWidth));
};
const stopResize = () => {
document.body.style.userSelect = previousUserSelect;
window.removeEventListener("pointermove", handleMove);
window.removeEventListener("pointerup", stopResize);
window.removeEventListener("pointercancel", stopResize);
};
window.addEventListener("pointermove", handleMove);
window.addEventListener("pointerup", stopResize);
window.addEventListener("pointercancel", stopResize);
},
[maxWidth, minEditorWidth, minWidth],
);
return (
<section className={cn("contents", className)}>
<aside
className="min-h-0 shrink-0 overflow-hidden border-r bg-background"
style={{ width }}
>
<FileTree
footer={footer}
gitStatus={gitStatus}
header={header}
initialExpansion={initialExpansion}
onActivePathChange={onActivePathChange}
onOpenFile={onOpenFile}
onSelectionChange={onSelectionChange}
paths={paths}
renderContextMenu={renderContextMenu}
renderRowDecoration={renderRowDecoration}
selectedPaths={selectedPaths}
/>
</aside>
<hr
aria-label="Resize sidebar"
aria-orientation="vertical"
aria-valuemax={maxWidth}
aria-valuemin={minWidth}
aria-valuenow={width}
className="relative z-10 m-0 w-1.5 shrink-0 cursor-col-resize border-0 bg-border/70 outline-none hover:bg-ring/35 focus-visible:bg-ring/45"
onPointerDown={handleResizeStart}
tabIndex={0}
/>
</section>
);
}
export default Sidebar;

View file

@ -0,0 +1,188 @@
import { useTerminal, Terminal as WTermTerminal } from "@wterm/react";
import { useCallback, useEffect, useRef } from "react";
import { cn } from "#/lib/utils";
type BaseTerminalProps = {
active?: boolean;
className?: string;
onInput?: (data: string) => void;
};
type Invoke = <T>(
command: string,
args?: Record<string, unknown>,
) => Promise<T>;
type Unlisten = () => void;
type TerminalOutputEvent = {
id: string;
data: string;
};
type TerminalExitEvent = {
id: string;
code: number;
signal?: string | null;
};
function createTerminalId() {
if (globalThis.crypto?.randomUUID) {
return globalThis.crypto.randomUUID();
}
return `terminal-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
export function BaseTerminal({
active = true,
className,
onInput,
}: BaseTerminalProps) {
const { ref, write, focus } = useTerminal();
const terminalIdRef = useRef(createTerminalId());
const invokeRef = useRef<Invoke | null>(null);
const sizeRef = useRef({ cols: 80, rows: 24 });
const unlistenRef = useRef<Unlisten[]>([]);
const connectionStartedRef = useRef(false);
const mountedRef = useRef(false);
const handleData = useCallback(
(data: string) => {
onInput?.(data);
invokeRef.current?.("write_terminal", {
id: terminalIdRef.current,
data,
});
},
[onInput],
);
const handleReady = useCallback(() => {
if (connectionStartedRef.current) {
focus();
return;
}
connectionStartedRef.current = true;
async function connectTerminal() {
try {
const [{ isTauri, invoke }, { listen }] = await Promise.all([
import("@tauri-apps/api/core"),
import("@tauri-apps/api/event"),
]);
if (!mountedRef.current) {
return;
}
if (!isTauri()) {
write("This component only works inside Tauri.\r\n");
return;
}
invokeRef.current = invoke as Invoke;
const unlistenOutput = await listen<TerminalOutputEvent>(
"terminal-output",
({ payload }) => {
if (payload.id !== terminalIdRef.current) {
return;
}
write(payload.data);
},
);
const unlistenExit = await listen<TerminalExitEvent>(
"terminal-exit",
({ payload }) => {
if (payload.id !== terminalIdRef.current) {
return;
}
const reason = payload.signal
? `signal ${payload.signal}`
: `code ${payload.code}`;
write(`\r\n[process exited: ${reason}]\r\n`);
},
);
unlistenRef.current.push(unlistenOutput, unlistenExit);
await invoke("spawn_terminal", {
id: terminalIdRef.current,
cols: sizeRef.current.cols,
rows: sizeRef.current.rows,
cwd: null,
shell: null,
});
focus();
} catch (error) {
console.error("Failed to start terminal", error);
connectionStartedRef.current = false;
write("Failed to start terminal.\r\n");
}
}
connectTerminal();
}, [focus, write]);
const handleResize = useCallback((cols: number, rows: number) => {
sizeRef.current = { cols, rows };
invokeRef.current?.("resize_terminal", {
id: terminalIdRef.current,
cols,
rows,
});
}, []);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
for (const unlisten of unlistenRef.current) {
unlisten();
}
unlistenRef.current = [];
invokeRef.current?.("kill_terminal", {
id: terminalIdRef.current,
});
connectionStartedRef.current = false;
};
}, []);
useEffect(() => {
if (active) {
focus();
}
}, [active, focus]);
return (
<section className={cn("h-full min-h-0", className)}>
<WTermTerminal
ref={ref}
autoResize
className="!h-full !rounded-none !border-0 !font-code !shadow-none"
cursorBlink
onData={handleData}
onReady={handleReady}
onResize={handleResize}
theme="kanade"
/>
</section>
);
}
export default BaseTerminal;

View file

@ -0,0 +1,2 @@
export { BaseTerminal } from "./base";
export { default, Terminal } from "./tab";

View file

@ -0,0 +1,194 @@
import { Plus, X } from "lucide-react";
import { type PointerEvent, useCallback, useState } from "react";
import { Button } from "#/components/ui/button";
import { cn } from "#/lib/utils";
import { BaseTerminal } from "./base";
type TerminalProps = {
className?: string;
defaultHeight?: number;
maxHeight?: number;
minHeight?: number;
minWorkspaceHeight?: number;
};
type TerminalTab = {
id: string;
title: string;
};
function createTabId() {
if (globalThis.crypto?.randomUUID) {
return globalThis.crypto.randomUUID();
}
return `terminal-tab-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function clamp(value: number, min: number, max: number) {
return Math.min(Math.max(value, min), max);
}
export function Terminal({
className,
defaultHeight = 220,
maxHeight = 520,
minHeight = 120,
minWorkspaceHeight = 220,
}: TerminalProps) {
const [height, setHeight] = useState(defaultHeight);
const [tabs, setTabs] = useState<TerminalTab[]>([
{ id: createTabId(), title: "Terminal 1" },
]);
const [activeTabId, setActiveTabId] = useState(tabs[0].id);
const handleResizeStart = useCallback(
(event: PointerEvent<HTMLHRElement>) => {
const startY = event.clientY;
const startHeight = height;
const resizeMaxHeight = Math.min(
maxHeight,
window.innerHeight - minWorkspaceHeight,
);
const previousUserSelect = document.body.style.userSelect;
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
document.body.style.userSelect = "none";
const handleMove = (moveEvent: globalThis.PointerEvent) => {
setHeight(
clamp(
startHeight + startY - moveEvent.clientY,
minHeight,
resizeMaxHeight,
),
);
};
const stopResize = () => {
document.body.style.userSelect = previousUserSelect;
window.removeEventListener("pointermove", handleMove);
window.removeEventListener("pointerup", stopResize);
window.removeEventListener("pointercancel", stopResize);
};
window.addEventListener("pointermove", handleMove);
window.addEventListener("pointerup", stopResize);
window.addEventListener("pointercancel", stopResize);
},
[height, maxHeight, minHeight, minWorkspaceHeight],
);
const addTab = () => {
const id = createTabId();
setTabs((currentTabs) => [
...currentTabs,
{ id, title: `Terminal ${currentTabs.length + 1}` },
]);
setActiveTabId(id);
};
const closeTab = (tabId: string) => {
setTabs((currentTabs) => {
if (currentTabs.length === 1) {
return currentTabs;
}
const closingIndex = currentTabs.findIndex((tab) => tab.id === tabId);
const nextTabs = currentTabs.filter((tab) => tab.id !== tabId);
if (activeTabId === tabId) {
const nextActiveTab = nextTabs[Math.max(0, closingIndex - 1)];
setActiveTabId(nextActiveTab.id);
}
return nextTabs;
});
};
return (
<section className={cn("contents", className)}>
<hr
aria-label="Resize terminal"
aria-orientation="horizontal"
aria-valuemax={maxHeight}
aria-valuemin={minHeight}
aria-valuenow={height}
className="relative m-0 h-1.5 shrink-0 cursor-row-resize border-0 bg-border/70 outline-none hover:bg-ring/35 focus-visible:bg-ring/45"
onPointerDown={handleResizeStart}
tabIndex={0}
/>
<section
className="flex min-h-0 shrink-0 flex-col border-t bg-background"
style={{ height }}
>
<div className="flex h-9v items-center gap-1 border-b bg-muted/40 px-2">
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto select-none">
{tabs.map((tab) => (
<div
className={cn(
"flex h-7 max-w-44 shrink-0 items-center rounded-md text-xs outline-none transition-colors",
tab.id === activeTabId
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:bg-background/70 hover:text-foreground",
)}
key={tab.id}
>
<button
className="min-w-0 flex-1 truncate px-2 text-left outline-none"
onClick={() => setActiveTabId(tab.id)}
type="button"
>
{tab.title}
</button>
<button
aria-label={`Close ${tab.title}`}
className={cn(
"mr-1 inline-flex size-4 shrink-0 items-center justify-center rounded-sm hover:bg-muted",
tabs.length === 1 && "cursor-not-allowed opacity-40",
)}
disabled={tabs.length === 1}
onClick={(event) => {
event.stopPropagation();
closeTab(tab.id);
}}
type="button"
>
<X className="size-3" />
</button>
</div>
))}
<Button
aria-label="New terminal"
className="shrink-0"
onClick={addTab}
size="icon-xs"
type="button"
variant="ghost"
>
<Plus className="size-3.5" />
</Button>
</div>
</div>
<div className="relative min-h-0 flex-1 overflow-hidden">
{tabs.map((tab) => (
<BaseTerminal
active={tab.id === activeTabId}
className={cn(
"absolute inset-0",
tab.id !== activeTabId && "pointer-events-none invisible",
)}
key={tab.id}
/>
))}
</div>
</section>
</section>
);
}
export default Terminal;

View file

@ -0,0 +1,67 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-[color-mix(in_oklch,var(--secondary),var(--foreground)_5%)] aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View file

@ -0,0 +1,280 @@
"use client"
import * as React from "react"
import { Menubar as MenubarPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { CheckIcon, ChevronRightIcon } from "lucide-react"
function Menubar({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
return (
<MenubarPrimitive.Root
data-slot="menubar"
className={cn(
"flex h-8 items-center gap-0.5 rounded-lg border p-[3px]",
className
)}
{...props}
/>
)
}
function MenubarMenu({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return (
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
)
}
function MenubarTrigger({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
return (
<MenubarPrimitive.Trigger
data-slot="menubar-trigger"
className={cn(
"flex items-center rounded-sm px-1.5 py-[2px] text-sm font-medium outline-hidden select-none hover:bg-muted aria-expanded:bg-muted",
className
)}
{...props}
/>
)
}
function MenubarContent({
className,
align = "start",
alignOffset = -4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
return (
<MenubarPortal>
<MenubarPrimitive.Content
data-slot="menubar-content"
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn("z-50 min-w-36 origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95", className )}
{...props}
/>
</MenubarPortal>
)
}
function MenubarItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenubarPrimitive.Item
data-slot="menubar-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/menubar-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive!",
className
)}
{...props}
/>
)
}
function MenubarCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.CheckboxItem
data-slot="menubar-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-1.5 flex size-4 items-center justify-center [&_svg:not([class*='size-'])]:size-4">
<MenubarPrimitive.ItemIndicator>
<CheckIcon
/>
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
)
}
function MenubarRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.RadioItem
data-slot="menubar-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-1.5 pl-7 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-1.5 flex size-4 items-center justify-center [&_svg:not([class*='size-'])]:size-4">
<MenubarPrimitive.ItemIndicator>
<CheckIcon
/>
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
)
}
function MenubarLabel({
className,
inset,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.Label
data-slot="menubar-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-sm font-medium data-inset:pl-7",
className
)}
{...props}
/>
)
}
function MenubarSeparator({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
return (
<MenubarPrimitive.Separator
data-slot="menubar-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function MenubarShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="menubar-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/menubar-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
function MenubarSub({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />
}
function MenubarSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.SubTrigger
data-slot="menubar-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-none select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</MenubarPrimitive.SubTrigger>
)
}
function MenubarSubContent({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
return (
<MenubarPrimitive.SubContent
data-slot="menubar-sub-content"
className={cn("z-50 min-w-32 origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
)
}
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
}

135
src/lib/git.ts Normal file
View file

@ -0,0 +1,135 @@
import type { GitStatusEntry as TreeGitStatusEntry } from "@pierre/trees";
export type GitFileStatus =
| "added"
| "deleted"
| "ignored"
| "modified"
| "renamed"
| "untracked";
export type GitStatusEntry = {
path: string;
status: GitFileStatus;
indexStatus: string;
worktreeStatus: string;
oldPath?: string | null;
};
export type GitStatusSummary = {
staged: number;
unstaged: number;
untracked: number;
};
export type GitRecentCommit = {
hash: string;
subject: string;
author: string;
relativeTime: string;
};
export type GitRepositoryStatus = {
rootPath: string;
branch: string;
head?: string | null;
ahead: number;
behind: number;
entries: GitStatusEntry[];
summary: GitStatusSummary;
recentCommits: GitRecentCommit[];
};
type Invoke = <T>(
command: string,
args?: Record<string, unknown>,
) => Promise<T>;
async function getTauriInvoke() {
const { invoke, isTauri } = await import("@tauri-apps/api/core");
if (!isTauri()) {
throw new Error("Git client only works inside Tauri.");
}
return invoke as Invoke;
}
export async function openRepositoryFolder() {
const { open } = await import("@tauri-apps/plugin-dialog");
const selected = await open({
directory: true,
multiple: false,
title: "Open Git Repository",
});
return typeof selected === "string" ? selected : null;
}
export async function loadRepositoryStatus(repoPath: string) {
const invoke = await getTauriInvoke();
return invoke<GitRepositoryStatus>("git_repository_status", { repoPath });
}
export async function loadFileDiff(repoPath: string, filePath?: string | null) {
const invoke = await getTauriInvoke();
return invoke<string>("git_diff_file", { repoPath, filePath });
}
export async function stagePaths(repoPath: string, paths?: readonly string[]) {
const invoke = await getTauriInvoke();
return invoke<void>("git_stage_paths", { repoPath, paths });
}
export async function unstagePaths(
repoPath: string,
paths?: readonly string[],
) {
const invoke = await getTauriInvoke();
return invoke<void>("git_unstage_paths", { repoPath, paths });
}
export async function discardPaths(repoPath: string, paths: readonly string[]) {
const invoke = await getTauriInvoke();
return invoke<void>("git_discard_paths", { repoPath, paths });
}
export async function commitChanges(repoPath: string, message: string) {
const invoke = await getTauriInvoke();
return invoke<void>("git_commit", { repoPath, message });
}
export async function openRepositoryFile(repoPath: string, path: string) {
const invoke = await getTauriInvoke();
return invoke<void>("git_open_path", {
repoPath,
filePath: path,
reveal: false,
});
}
export async function revealRepositoryFile(repoPath: string, path: string) {
const invoke = await getTauriInvoke();
return invoke<void>("git_open_path", {
repoPath,
filePath: path,
reveal: true,
});
}
export function toTreeGitStatus(
entries: readonly GitStatusEntry[],
): TreeGitStatusEntry[] {
return entries.map((entry) => ({
path: entry.path,
status: entry.status,
}));
}

6
src/lib/utils.ts Normal file
View file

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

86
src/routeTree.gen.ts Normal file
View file

@ -0,0 +1,86 @@
/* eslint-disable */
// @ts-nocheck
// noinspection JSUnusedGlobalSymbols
// This file was automatically generated by TanStack Router.
// You should NOT make any changes in this file as it will be overwritten.
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as SettingsRouteImport } from './routes/settings'
import { Route as IndexRouteImport } from './routes/index'
const SettingsRoute = SettingsRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/settings': typeof SettingsRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/settings': typeof SettingsRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/settings': typeof SettingsRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/settings'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/settings'
id: '__root__' | '/' | '/settings'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
SettingsRoute: typeof SettingsRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/settings': {
id: '/settings'
path: '/settings'
fullPath: '/settings'
preLoaderRoute: typeof SettingsRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
fullPath: '/'
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
SettingsRoute: SettingsRoute,
}
export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren)
._addFileTypes<FileRouteTypes>()
import type { getRouter } from './router.tsx'
import type { createStart } from '@tanstack/react-start'
declare module '@tanstack/react-start' {
interface Register {
ssr: true
router: Awaited<ReturnType<typeof getRouter>>
}
}

19
src/router.tsx Normal file
View file

@ -0,0 +1,19 @@
import { createRouter as createTanStackRouter } from '@tanstack/react-router'
import { routeTree } from './routeTree.gen'
export function getRouter() {
const router = createTanStackRouter({
routeTree,
scrollRestoration: true,
defaultPreload: 'intent',
defaultPreloadStaleTime: 0,
})
return router
}
declare module '@tanstack/react-router' {
interface Register {
router: ReturnType<typeof getRouter>
}
}

58
src/routes/__root.tsx Normal file
View file

@ -0,0 +1,58 @@
import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'
import { TanStackRouterDevtoolsPanel } from '@tanstack/react-router-devtools'
import { TanStackDevtools } from '@tanstack/react-devtools'
import appCss from '../styles.css?url'
import Header from '#/components/header'
const THEME_INIT_SCRIPT = `(function(){try{var stored=window.localStorage.getItem('theme');var mode=(stored==='light'||stored==='dark'||stored==='auto')?stored:'auto';var prefersDark=window.matchMedia('(prefers-color-scheme: dark)').matches;var resolved=mode==='auto'?(prefersDark?'dark':'light'):mode;var root=document.documentElement;root.classList.remove('light','dark');root.classList.add(resolved);if(mode==='auto'){root.removeAttribute('data-theme')}else{root.setAttribute('data-theme',mode)}root.style.colorScheme=resolved;}catch(e){}})();`
export const Route = createRootRoute({
head: () => ({
meta: [
{
charSet: 'utf-8',
},
{
name: 'viewport',
content: 'width=device-width, initial-scale=1',
},
{
title: 'TanStack Start Starter',
},
],
links: [
{
rel: 'stylesheet',
href: appCss,
},
],
}),
shellComponent: RootDocument,
})
function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
<HeadContent />
</head>
<body className="font-sans antialiased [overflow-wrap:anywhere] selection:bg-[rgba(79,184,178,0.24)]">
<Header />
{children}
<TanStackDevtools
config={{
position: 'bottom-right',
}}
plugins={[
{
name: 'Tanstack Router',
render: <TanStackRouterDevtoolsPanel />,
},
]}
/>
<Scripts />
</body>
</html>
)
}

12
src/routes/index.tsx Normal file
View file

@ -0,0 +1,12 @@
import { createFileRoute } from "@tanstack/react-router";
import { GitClient } from "#/components/gitClient";
export const Route = createFileRoute("/")({ component: App });
function App() {
return (
<main className="flex h-[calc(100vh-2.5rem)] w-screen min-w-0 flex-row overflow-hidden">
<GitClient />
</main>
);
}

9
src/routes/settings.tsx Normal file
View file

@ -0,0 +1,9 @@
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/settings')({
component: RouteComponent,
})
function RouteComponent() {
return <div>Hello "/settings"!</div>
}

168
src/styles.css Normal file
View file

@ -0,0 +1,168 @@
@import "@wterm/react/css";
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/inter";
@custom-variant dark (&:is(.dark *));
@plugin "@tailwindcss/typography";
.wterm.theme-kanade {
--term-bg: #fcf8f9;
--term-fg: #191017;
--term-cursor: #301d23;
--term-color-0: #5c5f77;
--term-color-1: #d20f39;
--term-color-2: #40a02b;
--term-color-3: #df8e1d;
--term-color-4: #1e66f5;
--term-color-5: #ea76cb;
--term-color-6: #179299;
--term-color-7: #acb0be;
--term-color-8: #6c6f85;
--term-color-9: #d20f39;
--term-color-10: #40a02b;
--term-color-11: #df8e1d;
--term-color-12: #1e66f5;
--term-color-13: #ea76cb;
--term-color-14: #179299;
--term-color-15: #bcc0cc;
--term-font-family: "JetBrainsMono Nerd Font Mono", monospace;
}
@theme inline {
--font-heading: var(--font-sans);
--font-sans: 'Inter Variable', sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: hsl(340 40% 98%);
--foreground: hsl(315 21% 8%);
--card: hsl(340 40% 98%);
--card-foreground: hsl(315 21% 8%);
--popover: hsl(340 40% 98%);
--popover-foreground: hsl(315 21% 8%);
--primary: hsl(340 25% 15%);
--primary-foreground: hsl(0 0% 98%);
--secondary: hsl(340 25% 95%);
--secondary-foreground: hsl(240 5.9% 10%);
--muted: hsl(340 20% 95%);
--muted-foreground: hsl(340 10% 60%);
--accent: hsl(340 25% 94%);
--accent-foreground: hsl(240 5.9% 10%);
--destructive: hsl(0 84.2% 60.2%);
--destructive-foreground: hsl(0 0% 98%);
--border: hsl(340 25% 90%);
--input: hsl(340 25% 90%);
--ring: hsl(315 21% 8%);
--chart-1: hsl(12 76% 61%);
--chart-2: hsl(173 58% 39%);
--chart-3: hsl(197 37% 24%);
--chart-4: hsl(43 74% 66%);
--chart-5: hsl(27 87% 67%);
--radius: 0.625rem;
--sidebar-background: hsl(340 25% 98%);
--sidebar-foreground: hsl(240 5.3% 26.1%);
--sidebar-primary: hsl(240 5.9% 10%);
--sidebar-primary-foreground: hsl(0 0% 98%);
--sidebar-accent: hsl(340 20% 95%);
--sidebar-accent-foreground: hsl(240 5.9% 10%);
--sidebar-border: hsl(340 20% 90%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
--scrollbar: hsla(340 10% 60% / 0.5);
--scrollbar-hover: hsla(340 10% 60% / 0.8);
--sidebar: hsl(0 0% 98%);
}
.dark {
--background: hsl(315 21% 8%);
--foreground: hsl(0 0% 98%);
--card: hsl(315 21% 8%);
--card-foreground: hsl(0 0% 98%);
--popover: hsl(315 21% 8%);
--popover-foreground: hsl(0 0% 98%);
--primary: hsl(0 0% 98%);
--primary-foreground: hsl(240 5.9% 10%);
--secondary: hsl(296, 18%, 15%);
--secondary-foreground: hsl(0 0% 98%);
--muted: hsl(296, 18%, 15%);
--muted-foreground: hsl(240 5% 68%);
--accent: hsl(296, 18%, 15%);
--accent-foreground: hsl(0 0% 98%);
--destructive: hsl(0 62.8% 30.6%);
--destructive-foreground: hsl(0 0% 98%);
--border: hsl(296, 18%, 15%);
--input: hsl(296, 18%, 15%);
--ring: hsl(240 4.9% 83.9%);
--chart-1: hsl(220 70% 50%);
--chart-2: hsl(160 60% 45%);
--chart-3: hsl(30 80% 55%);
--chart-4: hsl(280 65% 60%);
--chart-5: hsl(340 75% 55%);
--sidebar-background: hsl(240 5.9% 10%);
--sidebar-foreground: hsl(240 4.8% 95.9%);
--sidebar-primary: hsl(224.3 76.3% 48%);
--sidebar-primary-foreground: hsl(0 0% 100%);
--sidebar-accent: hsl(296, 18%, 15%);
--sidebar-accent-foreground: hsl(240 4.8% 95.9%);
--sidebar-border: hsl(296, 18%, 15%);
--sidebar-ring: hsl(217.2 91.2% 59.8%);
--sidebar: hsl(240 5.9% 10%);
--scrollbar: hsla(340 10% 60% / 0.5);
--scrollbar-hover: hsla(340 10% 60% / 0.8);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}

1
src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference types="vite/client" />

28
tsconfig.json Normal file
View file

@ -0,0 +1,28 @@
{
"include": ["**/*.ts", "**/*.tsx"],
"compilerOptions": {
"target": "ES2022",
"jsx": "react-jsx",
"module": "ESNext",
"paths": {
"#/*": ["./src/*"],
"@/*": ["./src/*"]
},
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client"],
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
/* Linting */
"skipLibCheck": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
}
}

10
tsconfig.node.json Normal file
View file

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

3
tsr.config.json Normal file
View file

@ -0,0 +1,3 @@
{
"target": "react"
}

14
vite.config.ts Normal file
View file

@ -0,0 +1,14 @@
import { defineConfig } from 'vite'
import { devtools } from '@tanstack/devtools-vite'
import { tanstackStart } from '@tanstack/react-start/plugin/vite'
import viteReact from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
const config = defineConfig({
resolve: { tsconfigPaths: true },
plugins: [devtools(), tailwindcss(), tanstackStart(), viteReact()],
})
export default config