init
18
.cta.json
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
25
components.json
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
16
public/logo.svg
Normal 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
|
After Width: | Height: | Size: 5.2 KiB |
BIN
public/logo512.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
25
public/manifest.json
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
26
src-tauri/Cargo.toml
Normal 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
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
13
src-tauri/capabilities/default.json
Normal 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
|
After Width: | Height: | Size: 3.4 KiB |
BIN
src-tauri/icons/128x128@2x.png
Normal file
|
After Width: | Height: | Size: 6.8 KiB |
BIN
src-tauri/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 974 B |
BIN
src-tauri/icons/Square107x107Logo.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
src-tauri/icons/Square142x142Logo.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src-tauri/icons/Square150x150Logo.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
src-tauri/icons/Square284x284Logo.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
src-tauri/icons/Square30x30Logo.png
Normal file
|
After Width: | Height: | Size: 903 B |
BIN
src-tauri/icons/Square310x310Logo.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
src-tauri/icons/Square44x44Logo.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
src-tauri/icons/Square71x71Logo.png
Normal file
|
After Width: | Height: | Size: 2 KiB |
BIN
src-tauri/icons/Square89x89Logo.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
src-tauri/icons/StoreLogo.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/icon.icns
Normal file
BIN
src-tauri/icons/icon.ico
Normal file
|
After Width: | Height: | Size: 85 KiB |
BIN
src-tauri/icons/icon.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
913
src-tauri/src/lib.rs
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
968
src/components/gitClient.tsx
Normal 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
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
234
src/components/sidebar/fileTree.tsx
Normal 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;
|
||||
117
src/components/sidebar/index.tsx
Normal 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;
|
||||
188
src/components/terminal/base.tsx
Normal 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;
|
||||
2
src/components/terminal/index.tsx
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { BaseTerminal } from "./base";
|
||||
export { default, Terminal } from "./tab";
|
||||
194
src/components/terminal/tab.tsx
Normal 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;
|
||||
67
src/components/ui/button.tsx
Normal 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 }
|
||||
280
src/components/ui/menubar.tsx
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
28
tsconfig.json
Normal 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
|
|
@ -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
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"target": "react"
|
||||
}
|
||||
14
vite.config.ts
Normal 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
|
||||