add all project files
13
.claude/settings.local.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(fuser -k 1420/tcp)",
|
||||
"Bash(pkill -f \"code-editor\")",
|
||||
"Bash(cargo check:*)",
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(pnpm tauri:*)",
|
||||
"Bash(ls -lh \"/home/luna/Desktop/code editor/src-tauri/target/release/bundle/deb/\"*.deb)",
|
||||
"Bash(ls -lh \"/home/luna/Desktop/code editor/src-tauri/target/release/bundle/rpm/\"*.rpm)"
|
||||
]
|
||||
}
|
||||
}
|
||||
24
.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
3
.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
|
||||
}
|
||||
29
PKGBUILD
Normal file
@@ -0,0 +1,29 @@
|
||||
pkgname=lunar-code
|
||||
pkgver=0.1.0
|
||||
pkgrel=1
|
||||
pkgdesc="Lunar Code - A lightweight code editor"
|
||||
arch=('x86_64')
|
||||
url="https://github.com/luna/lunar-code"
|
||||
license=('MIT')
|
||||
depends=('webkit2gtk-4.1' 'gtk3' 'cairo' 'glib2' 'hicolor-icon-theme')
|
||||
provides=('lunar-code')
|
||||
|
||||
package() {
|
||||
install -Dm755 "${srcdir}/../src-tauri/target/release/lunar-code" "${pkgdir}/usr/bin/lunar-code"
|
||||
|
||||
install -Dm644 /dev/stdin "${pkgdir}/usr/share/applications/lunar-code.desktop" << 'EOF'
|
||||
[Desktop Entry]
|
||||
Name=Lunar Code
|
||||
Comment=A lightweight code editor
|
||||
Exec=lunar-code %F
|
||||
Icon=lunar-code
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Development;TextEditor;IDE;
|
||||
Keywords=code;editor;programming;
|
||||
MimeType=text/plain;text/x-csrc;text/x-c++src;text/x-java;text/x-python;text/javascript;application/json;text/html;text/css;text/xml;text/x-rust;
|
||||
EOF
|
||||
|
||||
# Use a generic icon for now
|
||||
install -Dm644 /dev/stdin "${pkgdir}/usr/share/icons/hicolor/256x256/apps/lunar-code.png" < "${srcdir}/../src-tauri/icons/128x128@2x.png" 2>/dev/null || true
|
||||
}
|
||||
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>Lunar Code</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
lunar-code-0.1.0-1-x86_64.pkg.tar.zst
Normal file
83
package.json
Normal file
@@ -0,0 +1,83 @@
|
||||
{
|
||||
"name": "lunar-code",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
"@codemirror/autocomplete": "^6.20.1",
|
||||
"@codemirror/commands": "^6.10.3",
|
||||
"@codemirror/lang-cpp": "^6.0.3",
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-go": "^6.0.1",
|
||||
"@codemirror/lang-html": "^6.4.11",
|
||||
"@codemirror/lang-java": "^6.0.2",
|
||||
"@codemirror/lang-javascript": "^6.2.5",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/lang-php": "^6.0.2",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/lang-rust": "^6.0.2",
|
||||
"@codemirror/lang-sql": "^6.10.0",
|
||||
"@codemirror/lang-xml": "^6.1.0",
|
||||
"@codemirror/lang-yaml": "^6.1.3",
|
||||
"@codemirror/language": "^6.12.3",
|
||||
"@codemirror/legacy-modes": "^6.5.2",
|
||||
"@codemirror/lint": "^6.9.5",
|
||||
"@codemirror/search": "^6.6.0",
|
||||
"@codemirror/state": "^6.6.0",
|
||||
"@codemirror/view": "^6.41.0",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-context-menu": "^2.2.16",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-menubar": "^1.1.16",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@radix-ui/react-visually-hidden": "^1.2.4",
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"@tauri-apps/plugin-shell": "^2.3.5",
|
||||
"@uiw/codemirror-theme-vscode": "^4.25.9",
|
||||
"@xterm/addon-fit": "^0.11.0",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"lucide-react": "^1.7.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-resizable-panels": "^4.9.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.0.4"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild",
|
||||
"@tailwindcss/oxide"
|
||||
]
|
||||
}
|
||||
}
|
||||
1
package.json.tmp
Normal file
@@ -0,0 +1 @@
|
||||
{"pnpm":{"executionEnv":{"nodeVersion":"22"},"onlyBuiltDependencies":["esbuild","@tailwindcss/oxide"]}}
|
||||
1528
pkg/lunar-code/.BUILDINFO
Normal file
BIN
pkg/lunar-code/.MTREE
Normal file
19
pkg/lunar-code/.PKGINFO
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by makepkg 7.1.0
|
||||
# using fakeroot version 1.37.2
|
||||
pkgname = lunar-code
|
||||
pkgbase = lunar-code
|
||||
xdata = pkgtype=pkg
|
||||
pkgver = 0.1.0-1
|
||||
pkgdesc = Lunar Code - A lightweight code editor
|
||||
url = https://github.com/luna/lunar-code
|
||||
builddate = 1775255169
|
||||
packager = Unknown Packager
|
||||
size = 11082428
|
||||
arch = x86_64
|
||||
license = MIT
|
||||
provides = lunar-code
|
||||
depend = webkit2gtk-4.1
|
||||
depend = gtk3
|
||||
depend = cairo
|
||||
depend = glib2
|
||||
depend = hicolor-icon-theme
|
||||
BIN
pkg/lunar-code/usr/bin/lunar-code
Executable file
10
pkg/lunar-code/usr/share/applications/lunar-code.desktop
Normal file
@@ -0,0 +1,10 @@
|
||||
[Desktop Entry]
|
||||
Name=Lunar Code
|
||||
Comment=A lightweight code editor
|
||||
Exec=lunar-code %F
|
||||
Icon=lunar-code
|
||||
Terminal=false
|
||||
Type=Application
|
||||
Categories=Development;TextEditor;IDE;
|
||||
Keywords=code;editor;programming;
|
||||
MimeType=text/plain;text/x-csrc;text/x-c++src;text/x-java;text/x-python;text/javascript;application/json;text/html;text/css;text/xml;text/x-rust;
|
||||
|
After Width: | Height: | Size: 6.8 KiB |
3311
pnpm-lock.yaml
generated
Normal file
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 |
BIN
release/Lunar Code-0.1.0-1.x86_64.rpm
Normal file
BIN
release/Lunar Code_0.1.0_amd64.deb
Normal file
BIN
release/lunar-code
Executable file
BIN
release/lunar-code-0.1.0-1-x86_64.pkg.tar.zst
Normal file
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
|
||||
5159
src-tauri/Cargo.lock
generated
Normal file
21
src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "lunar-code"
|
||||
version = "0.1.0"
|
||||
description = "Lunar Code - A lightweight code editor"
|
||||
authors = ["luna"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "lunar_code_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-fs = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
3
src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
43
src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"dialog:default",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
"dialog:allow-ask",
|
||||
"dialog:allow-confirm",
|
||||
"dialog:allow-message",
|
||||
"fs:default",
|
||||
"fs:read-all",
|
||||
"fs:write-all",
|
||||
"fs:scope-home-recursive",
|
||||
"shell:default",
|
||||
"shell:allow-open",
|
||||
{
|
||||
"identifier": "shell:allow-execute",
|
||||
"allow": [
|
||||
{
|
||||
"name": "exec-sh",
|
||||
"cmd": "sh",
|
||||
"args": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"identifier": "shell:allow-spawn",
|
||||
"allow": [
|
||||
{
|
||||
"name": "exec-sh",
|
||||
"cmd": "sh",
|
||||
"args": true
|
||||
}
|
||||
]
|
||||
},
|
||||
"shell:allow-stdin-write",
|
||||
"shell:allow-kill"
|
||||
]
|
||||
}
|
||||
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.0 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 |
232
src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,232 @@
|
||||
use std::fs;
|
||||
use std::io::BufRead;
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct DirEntry {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub is_dir: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn read_directory(path: String) -> Result<Vec<DirEntry>, String> {
|
||||
let dir_path = Path::new(&path);
|
||||
if !dir_path.is_dir() {
|
||||
return Err(format!("{} is not a directory", path));
|
||||
}
|
||||
|
||||
let mut entries: Vec<DirEntry> = Vec::new();
|
||||
let read_dir = fs::read_dir(dir_path).map_err(|e| e.to_string())?;
|
||||
|
||||
for entry in read_dir {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
let file_name = entry.file_name().to_string_lossy().to_string();
|
||||
|
||||
if file_name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let file_path = entry.path().to_string_lossy().to_string();
|
||||
let is_dir = entry.path().is_dir();
|
||||
|
||||
entries.push(DirEntry {
|
||||
name: file_name,
|
||||
path: file_path,
|
||||
is_dir,
|
||||
});
|
||||
}
|
||||
|
||||
entries.sort_by(|a, b| {
|
||||
if a.is_dir == b.is_dir {
|
||||
a.name.to_lowercase().cmp(&b.name.to_lowercase())
|
||||
} else if a.is_dir {
|
||||
std::cmp::Ordering::Less
|
||||
} else {
|
||||
std::cmp::Ordering::Greater
|
||||
}
|
||||
});
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn read_file(path: String) -> Result<String, String> {
|
||||
fs::read_to_string(&path).map_err(|e| format!("Failed to read {}: {}", path, e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn write_file(path: String, contents: String) -> Result<(), String> {
|
||||
fs::write(&path, contents).map_err(|e| format!("Failed to write {}: {}", path, e))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn create_file(path: String) -> Result<(), String> {
|
||||
if Path::new(&path).exists() {
|
||||
return Err(format!("{} already exists", path));
|
||||
}
|
||||
fs::write(&path, "").map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn create_directory(path: String) -> Result<(), String> {
|
||||
fs::create_dir_all(&path).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn rename_path(old_path: String, new_path: String) -> Result<(), String> {
|
||||
fs::rename(&old_path, &new_path).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn delete_path(path: String) -> Result<(), String> {
|
||||
let p = Path::new(&path);
|
||||
if p.is_dir() {
|
||||
fs::remove_dir_all(p).map_err(|e| e.to_string())
|
||||
} else {
|
||||
fs::remove_file(p).map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct SearchMatch {
|
||||
pub file_path: String,
|
||||
pub file_name: String,
|
||||
pub line_number: usize,
|
||||
pub line_content: String,
|
||||
pub match_start: usize,
|
||||
pub match_end: usize,
|
||||
}
|
||||
|
||||
const SKIP_DIRS: &[&str] = &[
|
||||
"node_modules", "target", ".git", "dist", "build", "__pycache__",
|
||||
".next", ".nuxt", "vendor", ".venv", "venv",
|
||||
];
|
||||
|
||||
fn search_dir(
|
||||
dir: &Path,
|
||||
query: &str,
|
||||
case_sensitive: bool,
|
||||
results: &mut Vec<SearchMatch>,
|
||||
max_results: usize,
|
||||
) {
|
||||
if results.len() >= max_results {
|
||||
return;
|
||||
}
|
||||
|
||||
let read_dir = match fs::read_dir(dir) {
|
||||
Ok(rd) => rd,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let mut entries: Vec<_> = read_dir.filter_map(|e| e.ok()).collect();
|
||||
entries.sort_by(|a, b| a.file_name().cmp(&b.file_name()));
|
||||
|
||||
for entry in entries {
|
||||
if results.len() >= max_results {
|
||||
return;
|
||||
}
|
||||
|
||||
let name = entry.file_name().to_string_lossy().to_string();
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
if SKIP_DIRS.contains(&name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
search_dir(&path, query, case_sensitive, results, max_results);
|
||||
} else {
|
||||
search_file(&path, &name, query, case_sensitive, results, max_results);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn search_file(
|
||||
path: &Path,
|
||||
name: &str,
|
||||
query: &str,
|
||||
case_sensitive: bool,
|
||||
results: &mut Vec<SearchMatch>,
|
||||
max_results: usize,
|
||||
) {
|
||||
let file = match fs::File::open(path) {
|
||||
Ok(f) => f,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
// Skip binary/large files
|
||||
let metadata = match file.metadata() {
|
||||
Ok(m) => m,
|
||||
Err(_) => return,
|
||||
};
|
||||
if metadata.len() > 2_000_000 {
|
||||
return;
|
||||
}
|
||||
|
||||
let reader = std::io::BufReader::new(file);
|
||||
let query_lower = if case_sensitive { query.to_string() } else { query.to_lowercase() };
|
||||
let path_str = path.to_string_lossy().to_string();
|
||||
|
||||
for (i, line) in reader.lines().enumerate() {
|
||||
if results.len() >= max_results {
|
||||
return;
|
||||
}
|
||||
let line = match line {
|
||||
Ok(l) => l,
|
||||
Err(_) => return, // likely binary
|
||||
};
|
||||
let search_line = if case_sensitive { line.clone() } else { line.to_lowercase() };
|
||||
if let Some(pos) = search_line.find(&query_lower) {
|
||||
results.push(SearchMatch {
|
||||
file_path: path_str.clone(),
|
||||
file_name: name.to_string(),
|
||||
line_number: i + 1,
|
||||
line_content: line.chars().take(500).collect(),
|
||||
match_start: pos,
|
||||
match_end: pos + query.len(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn search_in_files(
|
||||
dir: String,
|
||||
query: String,
|
||||
case_sensitive: bool,
|
||||
) -> Result<Vec<SearchMatch>, String> {
|
||||
let dir_path = Path::new(&dir);
|
||||
if !dir_path.is_dir() {
|
||||
return Err(format!("{} is not a directory", dir));
|
||||
}
|
||||
if query.is_empty() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let mut results = Vec::new();
|
||||
search_dir(dir_path, &query, case_sensitive, &mut results, 1000);
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
read_directory,
|
||||
read_file,
|
||||
write_file,
|
||||
create_file,
|
||||
create_directory,
|
||||
rename_path,
|
||||
delete_path,
|
||||
search_in_files,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
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() {
|
||||
lunar_code_lib::run()
|
||||
}
|
||||
45
src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Lunar Code",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.luna.lunar-code",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "pnpm build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Lunar Code",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"minWidth": 640,
|
||||
"minHeight": 480
|
||||
}
|
||||
],
|
||||
"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"
|
||||
]
|
||||
},
|
||||
"plugins": {
|
||||
"fs": {
|
||||
"requireLiteralLeadingDot": false
|
||||
},
|
||||
"shell": {
|
||||
"open": true
|
||||
}
|
||||
}
|
||||
}
|
||||
187
src/App.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { Panel, Group as PanelGroup, Separator as PanelResizeHandle } from "react-resizable-panels";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { Toaster } from "sonner";
|
||||
import { AppMenubar } from "@/components/Menubar";
|
||||
import { FileTree } from "@/components/FileTree";
|
||||
import { SearchPanel } from "@/components/SearchPanel";
|
||||
import { EditorTabs } from "@/components/EditorTabs";
|
||||
import { Editor, type EditorHandle } from "@/components/Editor";
|
||||
import { StatusBar } from "@/components/StatusBar";
|
||||
import { CommandPalette } from "@/components/CommandPalette";
|
||||
import { QuickOpen } from "@/components/QuickOpen";
|
||||
import { SettingsDialog } from "@/components/SettingsDialog";
|
||||
import { GoToLineDialog } from "@/components/GoToLineDialog";
|
||||
import { WelcomeTab } from "@/components/WelcomeTab";
|
||||
import { Terminal } from "@/components/Terminal";
|
||||
import { useKeyboardShortcuts } from "@/hooks/useKeyboardShortcuts";
|
||||
import { useAutoSave } from "@/hooks/useAutoSave";
|
||||
import { useStore, actions } from "@/lib/store";
|
||||
import { applyTheme, loadCustomCSS } from "@/lib/themes";
|
||||
|
||||
function App() {
|
||||
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
||||
const [quickOpenOpen, setQuickOpenOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [goToLineOpen, setGoToLineOpen] = useState(false);
|
||||
const { sidebarVisible, sidebarView, terminalVisible, splitEditorPath, tabs, activeTabPath, settings } = useStore();
|
||||
const editorRef = useRef<EditorHandle>(null);
|
||||
const splitEditorRef = useRef<EditorHandle>(null);
|
||||
|
||||
const activeTab = tabs.find((t) => t.path === activeTabPath);
|
||||
|
||||
const openCommandPalette = useCallback(() => setCommandPaletteOpen(true), []);
|
||||
const openQuickOpen = useCallback(() => setQuickOpenOpen(true), []);
|
||||
const openSettings = useCallback(() => setSettingsOpen(true), []);
|
||||
const openGoToLine = useCallback(() => setGoToLineOpen(true), []);
|
||||
|
||||
const handleToggleTerminal = useCallback(() => actions.toggleTerminal(), []);
|
||||
const handleSearchInFiles = useCallback(() => actions.setSidebarView("search"), []);
|
||||
const handleToggleSplitEditor = useCallback(() => {
|
||||
if (splitEditorPath) {
|
||||
actions.setSplitEditor(null);
|
||||
} else if (activeTabPath) {
|
||||
actions.setSplitEditor(activeTabPath);
|
||||
}
|
||||
}, [splitEditorPath, activeTabPath]);
|
||||
|
||||
const handleGoToLine = useCallback((line: number) => {
|
||||
editorRef.current?.goToLine(line);
|
||||
}, []);
|
||||
|
||||
useKeyboardShortcuts({
|
||||
onOpenCommandPalette: openCommandPalette,
|
||||
onOpenQuickOpen: openQuickOpen,
|
||||
onToggleTerminal: handleToggleTerminal,
|
||||
onSearchInFiles: handleSearchInFiles,
|
||||
onGoToLine: openGoToLine,
|
||||
onSplitEditor: handleToggleSplitEditor,
|
||||
});
|
||||
|
||||
useAutoSave();
|
||||
|
||||
// Apply theme on mount and when theme changes
|
||||
useEffect(() => {
|
||||
applyTheme(settings.theme);
|
||||
}, [settings.theme]);
|
||||
|
||||
// Load custom CSS on mount and when path changes
|
||||
useEffect(() => {
|
||||
loadCustomCSS(settings.customCssPath);
|
||||
}, [settings.customCssPath]);
|
||||
|
||||
const maxLine = activeTab ? activeTab.content.split("\n").length : undefined;
|
||||
|
||||
return (
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="flex h-screen flex-col overflow-hidden bg-background text-foreground">
|
||||
{/* Menubar */}
|
||||
<AppMenubar
|
||||
onOpenCommandPalette={openCommandPalette}
|
||||
onOpenQuickOpen={openQuickOpen}
|
||||
onOpenSettings={openSettings}
|
||||
onToggleTerminal={handleToggleTerminal}
|
||||
onSearchInFiles={handleSearchInFiles}
|
||||
onGoToLine={openGoToLine}
|
||||
onSplitEditor={handleToggleSplitEditor}
|
||||
/>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<PanelGroup orientation="horizontal">
|
||||
{/* Sidebar */}
|
||||
{sidebarVisible && (
|
||||
<>
|
||||
<Panel
|
||||
defaultSize="20%"
|
||||
minSize="10%"
|
||||
maxSize="40%"
|
||||
className="bg-sidebar-background"
|
||||
>
|
||||
{sidebarView === "files" ? <FileTree /> : <SearchPanel />}
|
||||
</Panel>
|
||||
<PanelResizeHandle className="w-[1px] bg-border hover:bg-primary transition-colors" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Editor + Terminal vertical split */}
|
||||
<Panel minSize="30%">
|
||||
<PanelGroup orientation="vertical">
|
||||
{/* Editor area */}
|
||||
<Panel minSize="20%">
|
||||
<div className="flex h-full flex-col">
|
||||
<EditorTabs />
|
||||
<div className="flex-1 overflow-hidden bg-background">
|
||||
{tabs.length === 0 ? (
|
||||
<WelcomeTab />
|
||||
) : (
|
||||
<PanelGroup orientation="horizontal">
|
||||
<Panel minSize="30%">
|
||||
<Editor ref={editorRef} />
|
||||
</Panel>
|
||||
{splitEditorPath && (
|
||||
<>
|
||||
<PanelResizeHandle className="w-[1px] bg-border hover:bg-primary transition-colors" />
|
||||
<Panel minSize="20%">
|
||||
<Editor ref={splitEditorRef} tabPath={splitEditorPath} />
|
||||
</Panel>
|
||||
</>
|
||||
)}
|
||||
</PanelGroup>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
{/* Terminal */}
|
||||
{terminalVisible && (
|
||||
<>
|
||||
<PanelResizeHandle className="h-[1px] bg-border hover:bg-primary transition-colors" />
|
||||
<Panel defaultSize="30%" minSize="10%" maxSize="70%">
|
||||
<Terminal />
|
||||
</Panel>
|
||||
</>
|
||||
)}
|
||||
</PanelGroup>
|
||||
</Panel>
|
||||
</PanelGroup>
|
||||
</div>
|
||||
|
||||
{/* Status bar */}
|
||||
<StatusBar />
|
||||
</div>
|
||||
|
||||
{/* Dialogs */}
|
||||
<CommandPalette
|
||||
open={commandPaletteOpen}
|
||||
onOpenChange={setCommandPaletteOpen}
|
||||
onOpenSettings={openSettings}
|
||||
onToggleTerminal={handleToggleTerminal}
|
||||
onSearchInFiles={handleSearchInFiles}
|
||||
onGoToLine={openGoToLine}
|
||||
onSplitEditor={handleToggleSplitEditor}
|
||||
/>
|
||||
<QuickOpen open={quickOpenOpen} onOpenChange={setQuickOpenOpen} />
|
||||
<SettingsDialog open={settingsOpen} onOpenChange={setSettingsOpen} />
|
||||
<GoToLineDialog
|
||||
open={goToLineOpen}
|
||||
onOpenChange={setGoToLineOpen}
|
||||
onGoToLine={handleGoToLine}
|
||||
maxLine={maxLine}
|
||||
/>
|
||||
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
toastOptions={{
|
||||
style: {
|
||||
background: "var(--color-card)",
|
||||
border: "1px solid var(--color-border)",
|
||||
color: "var(--color-foreground)",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
187
src/components/CommandPalette.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { useCallback } from "react";
|
||||
import { Command } from "cmdk";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||
import { actions, useStore } from "@/lib/store";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface CommandPaletteProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onOpenSettings: () => void;
|
||||
onToggleTerminal: () => void;
|
||||
onSearchInFiles: () => void;
|
||||
onGoToLine: () => void;
|
||||
onSplitEditor: () => void;
|
||||
}
|
||||
|
||||
const itemClass =
|
||||
"flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm text-foreground outline-none transition-colors aria-selected:bg-accent aria-selected:text-accent-foreground";
|
||||
|
||||
export function CommandPalette({
|
||||
open: isOpen,
|
||||
onOpenChange,
|
||||
onOpenSettings,
|
||||
onToggleTerminal,
|
||||
onSearchInFiles,
|
||||
onGoToLine,
|
||||
onSplitEditor,
|
||||
}: CommandPaletteProps) {
|
||||
const store = useStore();
|
||||
const activeTab = store.tabs.find((t) => t.path === store.activeTabPath);
|
||||
|
||||
const close = useCallback(() => onOpenChange(false), [onOpenChange]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!activeTab) return;
|
||||
try {
|
||||
await invoke("write_file", { path: activeTab.path, contents: activeTab.content });
|
||||
actions.markSaved(activeTab.path);
|
||||
toast.success("File saved");
|
||||
} catch (err) {
|
||||
toast.error("Failed to save: " + String(err));
|
||||
}
|
||||
close();
|
||||
}, [activeTab, close]);
|
||||
|
||||
const handleSaveAll = useCallback(async () => {
|
||||
for (const tab of store.tabs) {
|
||||
if (tab.content !== tab.savedContent) {
|
||||
try {
|
||||
await invoke("write_file", { path: tab.path, contents: tab.content });
|
||||
actions.markSaved(tab.path);
|
||||
} catch (err) {
|
||||
toast.error(`Failed to save ${tab.name}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
toast.success("All files saved");
|
||||
close();
|
||||
}, [store.tabs, close]);
|
||||
|
||||
const handleOpenFolder = useCallback(async () => {
|
||||
const selected = await open({ directory: true, multiple: false });
|
||||
if (selected) {
|
||||
actions.setWorkspace(selected as string);
|
||||
}
|
||||
close();
|
||||
}, [close]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="p-0 gap-0 max-w-[500px] overflow-hidden border-border">
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>Command Palette</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
<Command className="bg-card" loop>
|
||||
<Command.Input
|
||||
placeholder="Type a command..."
|
||||
className="h-10 w-full border-b border-border bg-transparent px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
<Command.List className="max-h-[300px] overflow-y-auto p-1">
|
||||
<Command.Empty className="py-6 text-center text-sm text-muted-foreground">
|
||||
No commands found.
|
||||
</Command.Empty>
|
||||
|
||||
<Command.Group heading="File">
|
||||
<Command.Item className={itemClass} onSelect={handleOpenFolder}>
|
||||
Open Folder
|
||||
</Command.Item>
|
||||
<Command.Item className={itemClass} onSelect={handleSave}>
|
||||
Save File
|
||||
</Command.Item>
|
||||
<Command.Item className={itemClass} onSelect={handleSaveAll}>
|
||||
Save All
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
className={itemClass}
|
||||
onSelect={() => {
|
||||
if (activeTab) actions.closeTab(activeTab.path);
|
||||
close();
|
||||
}}
|
||||
>
|
||||
Close Tab
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
|
||||
<Command.Group heading="View">
|
||||
<Command.Item
|
||||
className={itemClass}
|
||||
onSelect={() => {
|
||||
actions.toggleSidebar();
|
||||
close();
|
||||
}}
|
||||
>
|
||||
Toggle Sidebar
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
className={itemClass}
|
||||
onSelect={() => {
|
||||
onToggleTerminal();
|
||||
close();
|
||||
}}
|
||||
>
|
||||
Toggle Terminal
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
className={itemClass}
|
||||
onSelect={() => {
|
||||
onSplitEditor();
|
||||
close();
|
||||
}}
|
||||
>
|
||||
Split Editor
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
className={itemClass}
|
||||
onSelect={() => {
|
||||
actions.updateSettings({
|
||||
theme: store.settings.theme === "dark" ? "light" : "dark",
|
||||
});
|
||||
close();
|
||||
}}
|
||||
>
|
||||
Toggle Theme
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
|
||||
<Command.Group heading="Go">
|
||||
<Command.Item
|
||||
className={itemClass}
|
||||
onSelect={() => {
|
||||
onGoToLine();
|
||||
close();
|
||||
}}
|
||||
>
|
||||
Go to Line
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
className={itemClass}
|
||||
onSelect={() => {
|
||||
onSearchInFiles();
|
||||
close();
|
||||
}}
|
||||
>
|
||||
Search in Files
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
|
||||
<Command.Group heading="Settings">
|
||||
<Command.Item
|
||||
className={itemClass}
|
||||
onSelect={() => {
|
||||
onOpenSettings();
|
||||
close();
|
||||
}}
|
||||
>
|
||||
Open Settings
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
</Command.List>
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
162
src/components/Editor.tsx
Normal file
@@ -0,0 +1,162 @@
|
||||
import { useEffect, useRef, useCallback, useImperativeHandle, forwardRef } from "react";
|
||||
import { EditorView, keymap, lineNumbers, highlightActiveLineGutter, highlightSpecialChars, drawSelection, dropCursor, rectangularSelection, crosshairCursor, highlightActiveLine } from "@codemirror/view";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { history, defaultKeymap, historyKeymap, indentWithTab } from "@codemirror/commands";
|
||||
import { foldGutter, indentOnInput, syntaxHighlighting, defaultHighlightStyle, bracketMatching, foldKeymap } from "@codemirror/language";
|
||||
import { closeBrackets, autocompletion, closeBracketsKeymap, completionKeymap } from "@codemirror/autocomplete";
|
||||
import { searchKeymap, highlightSelectionMatches } from "@codemirror/search";
|
||||
import { lintKeymap } from "@codemirror/lint";
|
||||
import { vscodeDark, vscodeLight } from "@uiw/codemirror-theme-vscode";
|
||||
import { actions, useStore } from "@/lib/store";
|
||||
import { getLanguageExtension } from "@/lib/languageDetect";
|
||||
import type { Extension } from "@codemirror/state";
|
||||
|
||||
export interface EditorHandle {
|
||||
getView(): EditorView | null;
|
||||
goToLine(line: number): void;
|
||||
}
|
||||
|
||||
interface EditorProps {
|
||||
tabPath?: string; // if provided, use this instead of store.activeTabPath (for split view)
|
||||
}
|
||||
|
||||
export const Editor = forwardRef<EditorHandle, EditorProps>(function Editor({ tabPath }, ref) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
const store = useStore();
|
||||
const effectivePath = tabPath ?? store.activeTabPath;
|
||||
const activeTab = store.tabs.find((t) => t.path === effectivePath);
|
||||
const currentPathRef = useRef<string | null>(null);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getView() {
|
||||
return viewRef.current;
|
||||
},
|
||||
goToLine(line: number) {
|
||||
const view = viewRef.current;
|
||||
if (!view) return;
|
||||
const doc = view.state.doc;
|
||||
const lineNum = Math.max(1, Math.min(line, doc.lines));
|
||||
const pos = doc.line(lineNum).from;
|
||||
view.dispatch({
|
||||
selection: { anchor: pos },
|
||||
scrollIntoView: true,
|
||||
});
|
||||
view.focus();
|
||||
},
|
||||
}));
|
||||
|
||||
const createView = useCallback(async (content: string, language: string, path: string) => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
if (viewRef.current) {
|
||||
viewRef.current.destroy();
|
||||
viewRef.current = null;
|
||||
}
|
||||
|
||||
const langExt = await getLanguageExtension(language);
|
||||
const theme = store.settings.theme === "light" ? vscodeLight : vscodeDark;
|
||||
const extensions: Extension[] = [
|
||||
lineNumbers(),
|
||||
highlightActiveLineGutter(),
|
||||
highlightSpecialChars(),
|
||||
history(),
|
||||
foldGutter(),
|
||||
drawSelection(),
|
||||
dropCursor(),
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
indentOnInput(),
|
||||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||
bracketMatching(),
|
||||
closeBrackets(),
|
||||
autocompletion(),
|
||||
rectangularSelection(),
|
||||
crosshairCursor(),
|
||||
highlightActiveLine(),
|
||||
highlightSelectionMatches(),
|
||||
keymap.of([
|
||||
...closeBracketsKeymap,
|
||||
...defaultKeymap,
|
||||
...searchKeymap,
|
||||
...historyKeymap,
|
||||
...foldKeymap,
|
||||
...completionKeymap,
|
||||
...lintKeymap,
|
||||
indentWithTab,
|
||||
]),
|
||||
theme,
|
||||
EditorState.tabSize.of(store.settings.tabSize),
|
||||
EditorView.theme({
|
||||
"&": {
|
||||
fontSize: store.settings.fontSize + "px",
|
||||
fontFamily: store.settings.fontFamily,
|
||||
},
|
||||
".cm-content": {
|
||||
fontFamily: store.settings.fontFamily,
|
||||
},
|
||||
".cm-gutters": {
|
||||
fontFamily: store.settings.fontFamily,
|
||||
},
|
||||
}),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
actions.updateContent(path, update.state.doc.toString());
|
||||
}
|
||||
if (update.selectionSet && !tabPath) {
|
||||
const pos = update.state.selection.main.head;
|
||||
const line = update.state.doc.lineAt(pos);
|
||||
actions.setCursor(line.number, pos - line.from + 1);
|
||||
}
|
||||
}),
|
||||
];
|
||||
|
||||
if (store.settings.wordWrap) {
|
||||
extensions.push(EditorView.lineWrapping);
|
||||
}
|
||||
|
||||
if (langExt) {
|
||||
extensions.push(langExt);
|
||||
}
|
||||
|
||||
const state = EditorState.create({
|
||||
doc: content,
|
||||
extensions,
|
||||
});
|
||||
|
||||
viewRef.current = new EditorView({
|
||||
state,
|
||||
parent: containerRef.current,
|
||||
});
|
||||
|
||||
currentPathRef.current = path;
|
||||
}, [store.settings.fontSize, store.settings.fontFamily, store.settings.tabSize, store.settings.wordWrap, store.settings.theme, tabPath]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeTab) {
|
||||
if (viewRef.current) {
|
||||
viewRef.current.destroy();
|
||||
viewRef.current = null;
|
||||
currentPathRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentPathRef.current !== activeTab.path) {
|
||||
createView(activeTab.content, activeTab.language, activeTab.path);
|
||||
}
|
||||
}, [activeTab, createView]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (viewRef.current) {
|
||||
viewRef.current.destroy();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (!activeTab) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div ref={containerRef} className="h-full w-full overflow-hidden" />;
|
||||
});
|
||||
141
src/components/EditorTabs.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { useState, useCallback, useRef } from "react";
|
||||
import { X, SplitSquareHorizontal } from "lucide-react";
|
||||
import { ask } from "@tauri-apps/plugin-dialog";
|
||||
import { useStore, actions } from "@/lib/store";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
} from "@/components/ui/context-menu";
|
||||
|
||||
export async function confirmCloseTab(path: string): Promise<boolean> {
|
||||
const state = actions.getState();
|
||||
const tab = state.tabs.find((t) => t.path === path);
|
||||
if (!tab) return true;
|
||||
if (tab.content === tab.savedContent) {
|
||||
actions.closeTab(path);
|
||||
return true;
|
||||
}
|
||||
const save = await ask(`"${tab.name}" has unsaved changes. Save before closing?`, {
|
||||
title: "Unsaved Changes",
|
||||
kind: "warning",
|
||||
okLabel: "Save",
|
||||
cancelLabel: "Don't Save",
|
||||
});
|
||||
if (save) {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
await invoke("write_file", { path: tab.path, contents: tab.content });
|
||||
actions.markSaved(path);
|
||||
}
|
||||
actions.closeTab(path);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function EditorTabs() {
|
||||
const { tabs, activeTabPath } = useStore();
|
||||
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||
const dragIndexRef = useRef<number | null>(null);
|
||||
|
||||
const handleDragStart = useCallback((e: React.DragEvent, index: number) => {
|
||||
dragIndexRef.current = index;
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setData("text/plain", String(index));
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "move";
|
||||
setDragOverIndex(index);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent, toIndex: number) => {
|
||||
e.preventDefault();
|
||||
const fromIndex = dragIndexRef.current;
|
||||
if (fromIndex !== null && fromIndex !== toIndex) {
|
||||
actions.reorderTabs(fromIndex, toIndex);
|
||||
}
|
||||
dragIndexRef.current = null;
|
||||
setDragOverIndex(null);
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
dragIndexRef.current = null;
|
||||
setDragOverIndex(null);
|
||||
}, []);
|
||||
|
||||
if (tabs.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="flex h-[35px] items-center bg-card overflow-x-auto">
|
||||
{tabs.map((tab, index) => {
|
||||
const isDirty = tab.content !== tab.savedContent;
|
||||
const isActive = tab.path === activeTabPath;
|
||||
|
||||
return (
|
||||
<ContextMenu key={tab.path}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"group flex h-full items-center gap-1 px-3 text-sm cursor-pointer select-none border-r border-card min-w-0 shrink-0 transition-colors",
|
||||
isActive
|
||||
? "bg-background text-foreground"
|
||||
: "bg-muted text-muted-foreground hover:bg-muted/80",
|
||||
dragOverIndex === index && "border-l-2 border-l-primary"
|
||||
)}
|
||||
style={{
|
||||
borderTop: isActive ? "1px solid var(--color-primary)" : "1px solid transparent",
|
||||
}}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, index)}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
onDrop={(e) => handleDrop(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragLeave={() => setDragOverIndex(null)}
|
||||
onClick={() => actions.setActiveTab(tab.path)}
|
||||
onMouseDown={(e) => {
|
||||
if (e.button === 1) {
|
||||
e.preventDefault();
|
||||
confirmCloseTab(tab.path);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="truncate max-w-[120px]">
|
||||
{isDirty && <span className="text-muted-foreground mr-0.5">·</span>}
|
||||
{tab.name}
|
||||
</span>
|
||||
<button
|
||||
className="ml-1 rounded-sm p-0.5 opacity-0 group-hover:opacity-100 hover:bg-secondary transition-opacity"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
confirmCloseTab(tab.path);
|
||||
}}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => confirmCloseTab(tab.path)}>
|
||||
Close
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => {
|
||||
const others = tabs.filter((t) => t.path !== tab.path);
|
||||
others.forEach((t) => confirmCloseTab(t.path));
|
||||
}}>
|
||||
Close Others
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => actions.setSplitEditor(tab.path)}>
|
||||
<SplitSquareHorizontal className="h-4 w-4 mr-2" />
|
||||
Open to the Side
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
138
src/components/FileTree.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { FolderOpen, FilePlus, FolderPlus, RefreshCw, Search } from "lucide-react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { FileTreeNode } from "@/components/FileTreeNode";
|
||||
import { useStore, actions } from "@/lib/store";
|
||||
|
||||
interface DirEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
is_dir: boolean;
|
||||
}
|
||||
|
||||
export function FileTree() {
|
||||
const { workspacePath, fileTreeFilter } = useStore();
|
||||
const [entries, setEntries] = useState<DirEntry[]>([]);
|
||||
|
||||
const loadEntries = useCallback(async () => {
|
||||
if (!workspacePath) return;
|
||||
try {
|
||||
const result = await invoke<DirEntry[]>("read_directory", { path: workspacePath });
|
||||
setEntries(result);
|
||||
} catch (err) {
|
||||
console.error("Failed to read workspace:", err);
|
||||
}
|
||||
}, [workspacePath]);
|
||||
|
||||
useEffect(() => {
|
||||
loadEntries();
|
||||
}, [loadEntries]);
|
||||
|
||||
const handleOpenFolder = useCallback(async () => {
|
||||
const selected = await open({ directory: true, multiple: false });
|
||||
if (selected) {
|
||||
actions.setWorkspace(selected as string);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleNewFile = useCallback(async () => {
|
||||
if (!workspacePath) return;
|
||||
const name = prompt("File name:");
|
||||
if (!name) return;
|
||||
try {
|
||||
await invoke("create_file", { path: workspacePath + "/" + name });
|
||||
loadEntries();
|
||||
} catch (err) {
|
||||
console.error("Failed to create file:", err);
|
||||
}
|
||||
}, [workspacePath, loadEntries]);
|
||||
|
||||
const handleNewFolder = useCallback(async () => {
|
||||
if (!workspacePath) return;
|
||||
const name = prompt("Folder name:");
|
||||
if (!name) return;
|
||||
try {
|
||||
await invoke("create_directory", { path: workspacePath + "/" + name });
|
||||
loadEntries();
|
||||
} catch (err) {
|
||||
console.error("Failed to create folder:", err);
|
||||
}
|
||||
}, [workspacePath, loadEntries]);
|
||||
|
||||
const folderName = workspacePath?.split("/").pop() ?? workspacePath?.split("\\").pop();
|
||||
|
||||
if (!workspacePath) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 px-4">
|
||||
<p className="text-sm text-muted-foreground text-center">No folder open</p>
|
||||
<Button variant="secondary" size="sm" onClick={handleOpenFolder} className="gap-1.5">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
Open Folder
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex h-8 items-center justify-between px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
|
||||
<span className="truncate">{folderName}</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={handleNewFile}>
|
||||
<FilePlus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>New File</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={handleNewFolder}>
|
||||
<FolderPlus className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>New Folder</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={loadEntries}>
|
||||
<RefreshCw className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Refresh</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-2 pb-1 shrink-0">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Filter files..."
|
||||
value={fileTreeFilter}
|
||||
onChange={(e) => actions.setFileTreeFilter(e.target.value)}
|
||||
className="h-6 pl-6 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="pb-4">
|
||||
{entries.map((entry) => (
|
||||
<FileTreeNode
|
||||
key={entry.path}
|
||||
entry={entry}
|
||||
depth={0}
|
||||
filter={fileTreeFilter}
|
||||
onRefresh={loadEntries}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
259
src/components/FileTreeNode.tsx
Normal file
@@ -0,0 +1,259 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { ChevronRight, ChevronDown, FileCode, FileJson, FileText, File, Folder, FolderOpen, FileType } from "lucide-react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { actions } from "@/lib/store";
|
||||
import { detectLanguage } from "@/lib/languageDetect";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
} from "@/components/ui/context-menu";
|
||||
|
||||
interface DirEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
is_dir: boolean;
|
||||
}
|
||||
|
||||
interface FileTreeNodeProps {
|
||||
entry: DirEntry;
|
||||
depth: number;
|
||||
filter?: string;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
function getFileIcon(name: string) {
|
||||
const ext = name.split(".").pop()?.toLowerCase();
|
||||
switch (ext) {
|
||||
case "js":
|
||||
case "jsx":
|
||||
case "ts":
|
||||
case "tsx":
|
||||
case "py":
|
||||
case "rs":
|
||||
case "go":
|
||||
case "java":
|
||||
case "cpp":
|
||||
case "c":
|
||||
case "h":
|
||||
case "php":
|
||||
return <FileCode className="h-4 w-4 shrink-0 text-muted-foreground" />;
|
||||
case "json":
|
||||
case "jsonc":
|
||||
return <FileJson className="h-4 w-4 shrink-0 text-yellow-500/70" />;
|
||||
case "md":
|
||||
case "txt":
|
||||
case "markdown":
|
||||
return <FileText className="h-4 w-4 shrink-0 text-muted-foreground" />;
|
||||
case "html":
|
||||
case "htm":
|
||||
case "css":
|
||||
case "scss":
|
||||
return <FileType className="h-4 w-4 shrink-0 text-orange-400/70" />;
|
||||
default:
|
||||
return <File className="h-4 w-4 shrink-0 text-muted-foreground" />;
|
||||
}
|
||||
}
|
||||
|
||||
export function FileTreeNode({ entry, depth, filter, onRefresh }: FileTreeNodeProps) {
|
||||
// If filter is active and this is a file that doesn't match, hide it
|
||||
const filterLower = filter?.toLowerCase() ?? "";
|
||||
const matchesFilter = !filterLower || entry.name.toLowerCase().includes(filterLower);
|
||||
|
||||
// Directories are always shown when filter is active (children might match)
|
||||
// Files are hidden if they don't match
|
||||
if (filterLower && !entry.is_dir && !matchesFilter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [children, setChildren] = useState<DirEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [renameValue, setRenameValue] = useState(entry.name);
|
||||
|
||||
const toggle = useCallback(async () => {
|
||||
if (!entry.is_dir) return;
|
||||
if (!expanded) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const entries = await invoke<DirEntry[]>("read_directory", { path: entry.path });
|
||||
setChildren(entries);
|
||||
} catch (err) {
|
||||
console.error("Failed to read directory:", err);
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
setExpanded(!expanded);
|
||||
}, [expanded, entry.is_dir, entry.path]);
|
||||
|
||||
const refreshChildren = useCallback(async () => {
|
||||
if (entry.is_dir && expanded) {
|
||||
try {
|
||||
const entries = await invoke<DirEntry[]>("read_directory", { path: entry.path });
|
||||
setChildren(entries);
|
||||
} catch (err) {
|
||||
console.error("Failed to refresh:", err);
|
||||
}
|
||||
}
|
||||
}, [entry.is_dir, entry.path, expanded]);
|
||||
|
||||
const handleClick = useCallback(async () => {
|
||||
if (entry.is_dir) {
|
||||
toggle();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const content = await invoke<string>("read_file", { path: entry.path });
|
||||
const language = detectLanguage(entry.name);
|
||||
actions.openFile(entry.path, entry.name, content, language);
|
||||
} catch (err) {
|
||||
console.error("Failed to open file:", err);
|
||||
}
|
||||
}, [entry, toggle]);
|
||||
|
||||
const handleNewFile = useCallback(async () => {
|
||||
const name = prompt("File name:");
|
||||
if (!name) return;
|
||||
try {
|
||||
await invoke("create_file", { path: entry.path + "/" + name });
|
||||
await refreshChildren();
|
||||
if (!expanded) setExpanded(true);
|
||||
onRefresh();
|
||||
} catch (err) {
|
||||
console.error("Failed to create file:", err);
|
||||
}
|
||||
}, [entry.path, expanded, refreshChildren, onRefresh]);
|
||||
|
||||
const handleNewFolder = useCallback(async () => {
|
||||
const name = prompt("Folder name:");
|
||||
if (!name) return;
|
||||
try {
|
||||
await invoke("create_directory", { path: entry.path + "/" + name });
|
||||
await refreshChildren();
|
||||
if (!expanded) setExpanded(true);
|
||||
onRefresh();
|
||||
} catch (err) {
|
||||
console.error("Failed to create folder:", err);
|
||||
}
|
||||
}, [entry.path, expanded, refreshChildren, onRefresh]);
|
||||
|
||||
const handleRename = useCallback(async () => {
|
||||
if (renameValue === entry.name || !renameValue.trim()) {
|
||||
setRenaming(false);
|
||||
return;
|
||||
}
|
||||
const parentPath = entry.path.substring(0, entry.path.lastIndexOf("/"));
|
||||
try {
|
||||
await invoke("rename_path", {
|
||||
oldPath: entry.path,
|
||||
newPath: parentPath + "/" + renameValue,
|
||||
});
|
||||
onRefresh();
|
||||
} catch (err) {
|
||||
console.error("Failed to rename:", err);
|
||||
}
|
||||
setRenaming(false);
|
||||
}, [renameValue, entry, onRefresh]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!confirm(`Delete "${entry.name}"?`)) return;
|
||||
try {
|
||||
await invoke("delete_path", { path: entry.path });
|
||||
onRefresh();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete:", err);
|
||||
}
|
||||
}, [entry, onRefresh]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center h-[22px] cursor-pointer hover:bg-[#2a2d2e] select-none",
|
||||
"text-sm text-sidebar-foreground"
|
||||
)}
|
||||
style={{ paddingLeft: depth * 12 + 4 }}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{entry.is_dir ? (
|
||||
<>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
{expanded ? (
|
||||
<FolderOpen className="h-4 w-4 shrink-0 ml-0.5 text-yellow-500/70" />
|
||||
) : (
|
||||
<Folder className="h-4 w-4 shrink-0 ml-0.5 text-yellow-500/70" />
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="w-4 shrink-0" />
|
||||
{getFileIcon(entry.name)}
|
||||
</>
|
||||
)}
|
||||
{renaming ? (
|
||||
<input
|
||||
className="ml-1 flex-1 bg-[#3c3c3c] text-foreground text-sm px-1 py-0 border border-[#007acc] outline-none rounded-sm"
|
||||
value={renameValue}
|
||||
onChange={(e) => setRenameValue(e.target.value)}
|
||||
onBlur={handleRename}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleRename();
|
||||
if (e.key === "Escape") setRenaming(false);
|
||||
}}
|
||||
autoFocus
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span className="ml-1 truncate">{entry.name}</span>
|
||||
)}
|
||||
{loading && <span className="ml-1 text-xs text-muted-foreground">...</span>}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
{entry.is_dir && (
|
||||
<>
|
||||
<ContextMenuItem onClick={handleNewFile}>New File</ContextMenuItem>
|
||||
<ContextMenuItem onClick={handleNewFolder}>New Folder</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
setRenameValue(entry.name);
|
||||
setRenaming(true);
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem className="text-destructive focus:text-destructive" onClick={handleDelete}>
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
{entry.is_dir && (expanded || !!filterLower) && (
|
||||
<div>
|
||||
{children.map((child) => (
|
||||
<FileTreeNode
|
||||
key={child.path}
|
||||
entry={child}
|
||||
depth={depth + 1}
|
||||
filter={filter}
|
||||
onRefresh={refreshChildren}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
src/components/GoToLineDialog.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { Dialog, DialogContent, DialogTitle, DialogHeader } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface GoToLineDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onGoToLine: (line: number) => void;
|
||||
maxLine?: number;
|
||||
}
|
||||
|
||||
export function GoToLineDialog({ open, onOpenChange, onGoToLine, maxLine }: GoToLineDialogProps) {
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
const handleGo = useCallback(() => {
|
||||
const line = parseInt(value, 10);
|
||||
if (!isNaN(line) && line > 0) {
|
||||
onGoToLine(line);
|
||||
onOpenChange(false);
|
||||
setValue("");
|
||||
}
|
||||
}, [value, onGoToLine, onOpenChange]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[300px] gap-3">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Go to Line</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={maxLine}
|
||||
placeholder={maxLine ? `Line (1-${maxLine})` : "Line number"}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleGo();
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<Button onClick={handleGo} size="default">
|
||||
Go
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
186
src/components/Menubar.tsx
Normal file
@@ -0,0 +1,186 @@
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
Menubar as MenubarRoot,
|
||||
MenubarMenu,
|
||||
MenubarTrigger,
|
||||
MenubarContent,
|
||||
MenubarItem,
|
||||
MenubarSeparator,
|
||||
MenubarShortcut,
|
||||
MenubarSub,
|
||||
MenubarSubTrigger,
|
||||
MenubarSubContent,
|
||||
} from "@/components/ui/menubar";
|
||||
import { actions, useStore } from "@/lib/store";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface MenubarProps {
|
||||
onOpenCommandPalette: () => void;
|
||||
onOpenQuickOpen: () => void;
|
||||
onOpenSettings: () => void;
|
||||
onToggleTerminal: () => void;
|
||||
onSearchInFiles: () => void;
|
||||
onGoToLine: () => void;
|
||||
onSplitEditor: () => void;
|
||||
}
|
||||
|
||||
export function AppMenubar({
|
||||
onOpenCommandPalette,
|
||||
onOpenQuickOpen,
|
||||
onOpenSettings,
|
||||
onToggleTerminal,
|
||||
onSearchInFiles,
|
||||
onGoToLine,
|
||||
onSplitEditor,
|
||||
}: MenubarProps) {
|
||||
const store = useStore();
|
||||
const activeTab = store.tabs.find((t) => t.path === store.activeTabPath);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!activeTab) return;
|
||||
try {
|
||||
await invoke("write_file", { path: activeTab.path, contents: activeTab.content });
|
||||
actions.markSaved(activeTab.path);
|
||||
toast.success("File saved");
|
||||
} catch (err) {
|
||||
toast.error("Failed to save: " + String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveAll = async () => {
|
||||
for (const tab of store.tabs) {
|
||||
if (tab.content !== tab.savedContent) {
|
||||
try {
|
||||
await invoke("write_file", { path: tab.path, contents: tab.content });
|
||||
actions.markSaved(tab.path);
|
||||
} catch (err) {
|
||||
toast.error(`Failed to save ${tab.name}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
toast.success("All files saved");
|
||||
};
|
||||
|
||||
const handleOpenFolder = async () => {
|
||||
const selected = await open({ directory: true, multiple: false });
|
||||
if (selected) {
|
||||
actions.setWorkspace(selected as string);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<MenubarRoot>
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger>File</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem onClick={handleOpenFolder}>
|
||||
Open Folder
|
||||
</MenubarItem>
|
||||
{store.recentWorkspaces.length > 0 && (
|
||||
<MenubarSub>
|
||||
<MenubarSubTrigger>Open Recent</MenubarSubTrigger>
|
||||
<MenubarSubContent>
|
||||
{store.recentWorkspaces.slice(0, 5).map((path) => {
|
||||
const name = path.split("/").pop() ?? path.split("\\").pop() ?? path;
|
||||
return (
|
||||
<MenubarItem key={path} onClick={() => actions.setWorkspace(path)}>
|
||||
{name}
|
||||
</MenubarItem>
|
||||
);
|
||||
})}
|
||||
</MenubarSubContent>
|
||||
</MenubarSub>
|
||||
)}
|
||||
<MenubarSeparator />
|
||||
<MenubarItem onClick={handleSave} disabled={!activeTab}>
|
||||
Save <MenubarShortcut>Ctrl+S</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarItem onClick={handleSaveAll}>
|
||||
Save All <MenubarShortcut>Ctrl+Shift+S</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem onClick={() => activeTab && actions.closeTab(activeTab.path)} disabled={!activeTab}>
|
||||
Close Tab <MenubarShortcut>Ctrl+W</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger>Edit</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem onClick={() => document.execCommand("undo")}>
|
||||
Undo <MenubarShortcut>Ctrl+Z</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarItem onClick={() => document.execCommand("redo")}>
|
||||
Redo <MenubarShortcut>Ctrl+Shift+Z</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem onClick={() => document.execCommand("cut")}>
|
||||
Cut <MenubarShortcut>Ctrl+X</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarItem onClick={() => document.execCommand("copy")}>
|
||||
Copy <MenubarShortcut>Ctrl+C</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarItem onClick={() => document.execCommand("paste")}>
|
||||
Paste <MenubarShortcut>Ctrl+V</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem onClick={onOpenQuickOpen}>
|
||||
Find File <MenubarShortcut>Ctrl+P</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarItem onClick={onSearchInFiles}>
|
||||
Search in Files <MenubarShortcut>Ctrl+Shift+F</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarItem onClick={onGoToLine} disabled={!activeTab}>
|
||||
Go to Line <MenubarShortcut>Ctrl+G</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger>View</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem onClick={() => actions.toggleSidebar()}>
|
||||
Toggle Sidebar <MenubarShortcut>Ctrl+B</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarItem onClick={onToggleTerminal}>
|
||||
Toggle Terminal <MenubarShortcut>Ctrl+`</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarItem onClick={onSplitEditor} disabled={!activeTab}>
|
||||
Split Editor <MenubarShortcut>Ctrl+\</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem onClick={onOpenCommandPalette}>
|
||||
Command Palette <MenubarShortcut>Ctrl+Shift+P</MenubarShortcut>
|
||||
</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarSub>
|
||||
<MenubarSubTrigger>Theme</MenubarSubTrigger>
|
||||
<MenubarSubContent>
|
||||
<MenubarItem onClick={() => actions.updateSettings({ theme: "dark" })}>
|
||||
Dark
|
||||
</MenubarItem>
|
||||
<MenubarItem onClick={() => actions.updateSettings({ theme: "light" })}>
|
||||
Light
|
||||
</MenubarItem>
|
||||
</MenubarSubContent>
|
||||
</MenubarSub>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
|
||||
<MenubarMenu>
|
||||
<MenubarTrigger>Help</MenubarTrigger>
|
||||
<MenubarContent>
|
||||
<MenubarItem onClick={onOpenSettings}>
|
||||
Settings
|
||||
</MenubarItem>
|
||||
<MenubarSeparator />
|
||||
<MenubarItem disabled>
|
||||
About Lunar Code
|
||||
</MenubarItem>
|
||||
</MenubarContent>
|
||||
</MenubarMenu>
|
||||
</MenubarRoot>
|
||||
);
|
||||
}
|
||||
109
src/components/QuickOpen.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { Command } from "cmdk";
|
||||
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { VisuallyHidden } from "@radix-ui/react-visually-hidden";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { actions, useStore } from "@/lib/store";
|
||||
import { detectLanguage } from "@/lib/languageDetect";
|
||||
import { FileCode } from "lucide-react";
|
||||
|
||||
interface QuickOpenProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
interface FileEntry {
|
||||
name: string;
|
||||
path: string;
|
||||
relative: string;
|
||||
}
|
||||
|
||||
async function collectFiles(dir: string, base: string, result: FileEntry[], depth: number) {
|
||||
if (depth > 5) return;
|
||||
try {
|
||||
const entries = await invoke<{ name: string; path: string; is_dir: boolean }[]>("read_directory", { path: dir });
|
||||
for (const entry of entries) {
|
||||
const relative = base ? base + "/" + entry.name : entry.name;
|
||||
if (entry.is_dir) {
|
||||
if (entry.name === "node_modules" || entry.name === "target" || entry.name === ".git" || entry.name === "dist") continue;
|
||||
await collectFiles(entry.path, relative, result, depth + 1);
|
||||
} else {
|
||||
result.push({ name: entry.name, path: entry.path, relative });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// skip inaccessible dirs
|
||||
}
|
||||
}
|
||||
|
||||
export function QuickOpen({ open: isOpen, onOpenChange }: QuickOpenProps) {
|
||||
const { workspacePath } = useStore();
|
||||
const [files, setFiles] = useState<FileEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && workspacePath) {
|
||||
setLoading(true);
|
||||
const result: FileEntry[] = [];
|
||||
collectFiles(workspacePath, "", result, 0).then(() => {
|
||||
setFiles(result);
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}, [isOpen, workspacePath]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (filePath: string, fileName: string) => {
|
||||
try {
|
||||
const content = await invoke<string>("read_file", { path: filePath });
|
||||
const language = detectLanguage(fileName);
|
||||
actions.openFile(filePath, fileName, content, language);
|
||||
} catch (err) {
|
||||
console.error("Failed to open file:", err);
|
||||
}
|
||||
onOpenChange(false);
|
||||
},
|
||||
[onOpenChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="p-0 gap-0 max-w-[500px] overflow-hidden border-border">
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>Quick Open</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
<Command className="bg-card" loop>
|
||||
<Command.Input
|
||||
placeholder="Search files..."
|
||||
className="h-10 w-full border-b border-border bg-transparent px-3 text-sm text-foreground outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
<Command.List className="max-h-[300px] overflow-y-auto p-1">
|
||||
{loading ? (
|
||||
<div className="py-6 text-center text-sm text-muted-foreground">Loading...</div>
|
||||
) : (
|
||||
<>
|
||||
<Command.Empty className="py-6 text-center text-sm text-muted-foreground">
|
||||
No files found.
|
||||
</Command.Empty>
|
||||
{files.map((file) => (
|
||||
<Command.Item
|
||||
key={file.path}
|
||||
value={file.relative}
|
||||
className="flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm text-foreground outline-none transition-colors aria-selected:bg-accent aria-selected:text-accent-foreground"
|
||||
onSelect={() => handleSelect(file.path, file.name)}
|
||||
>
|
||||
<FileCode className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
<div className="flex flex-col min-w-0">
|
||||
<span className="truncate">{file.name}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{file.relative}</span>
|
||||
</div>
|
||||
</Command.Item>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Command.List>
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
166
src/components/SearchPanel.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { Search, CaseSensitive, Regex, FileCode, X } from "lucide-react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useStore, actions, type SearchResult } from "@/lib/store";
|
||||
import { detectLanguage } from "@/lib/languageDetect";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function SearchPanel() {
|
||||
const { workspacePath, searchQuery, searchResults, searchCaseSensitive, searchRegex } = useStore();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [localQuery, setLocalQuery] = useState(searchQuery);
|
||||
|
||||
const handleSearch = useCallback(async () => {
|
||||
if (!workspacePath || !localQuery.trim()) {
|
||||
actions.setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
actions.setSearchQuery(localQuery);
|
||||
|
||||
try {
|
||||
const results = await invoke<SearchResult[]>("search_in_files", {
|
||||
dir: workspacePath,
|
||||
query: localQuery,
|
||||
caseSensitive: searchCaseSensitive,
|
||||
});
|
||||
actions.setSearchResults(results);
|
||||
} catch (err) {
|
||||
console.error("Search failed:", err);
|
||||
actions.setSearchResults([]);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [workspacePath, localQuery, searchCaseSensitive]);
|
||||
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
handleSearch();
|
||||
}
|
||||
}, [handleSearch]);
|
||||
|
||||
const handleResultClick = useCallback(async (result: SearchResult) => {
|
||||
try {
|
||||
const content = await invoke<string>("read_file", { path: result.filePath });
|
||||
const language = detectLanguage(result.fileName);
|
||||
actions.openFile(result.filePath, result.fileName, content, language);
|
||||
} catch (err) {
|
||||
console.error("Failed to open file:", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Group results by file
|
||||
const grouped = searchResults.reduce<Record<string, SearchResult[]>>((acc, r) => {
|
||||
if (!acc[r.filePath]) acc[r.filePath] = [];
|
||||
acc[r.filePath].push(r);
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex h-8 items-center px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground shrink-0">
|
||||
<span>Search</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5 ml-auto"
|
||||
onClick={() => actions.setSidebarView("files")}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="px-2 pb-2 space-y-1 shrink-0">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search in files..."
|
||||
value={localQuery}
|
||||
onChange={(e) => setLocalQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="h-7 pl-6 pr-16 text-xs"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="absolute right-1 top-1/2 -translate-y-1/2 flex gap-0.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-5 w-5", searchCaseSensitive && "bg-accent")}
|
||||
onClick={() => actions.setSearchCaseSensitive(!searchCaseSensitive)}
|
||||
>
|
||||
<CaseSensitive className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Match Case</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn("h-5 w-5", searchRegex && "bg-accent")}
|
||||
onClick={() => actions.setSearchRegex(!searchRegex)}
|
||||
>
|
||||
<Regex className="h-3 w-3" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Use Regex</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="px-1 pb-4">
|
||||
{loading && (
|
||||
<p className="px-2 py-4 text-xs text-muted-foreground text-center">Searching...</p>
|
||||
)}
|
||||
|
||||
{!loading && searchQuery && searchResults.length === 0 && (
|
||||
<p className="px-2 py-4 text-xs text-muted-foreground text-center">No results found</p>
|
||||
)}
|
||||
|
||||
{!loading && Object.entries(grouped).map(([filePath, results]) => {
|
||||
const relPath = workspacePath
|
||||
? filePath.replace(workspacePath + "/", "")
|
||||
: filePath;
|
||||
|
||||
return (
|
||||
<div key={filePath} className="mb-1">
|
||||
<div className="flex items-center gap-1 px-2 py-0.5 text-xs font-medium text-foreground">
|
||||
<FileCode className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="truncate">{relPath}</span>
|
||||
<span className="text-muted-foreground ml-auto shrink-0">{results.length}</span>
|
||||
</div>
|
||||
{results.map((result, i) => (
|
||||
<div
|
||||
key={`${filePath}:${result.lineNumber}:${i}`}
|
||||
className="flex items-start gap-1 px-4 py-0.5 text-xs cursor-pointer hover:bg-sidebar-accent rounded-sm"
|
||||
onClick={() => handleResultClick(result)}
|
||||
>
|
||||
<span className="text-muted-foreground shrink-0 w-8 text-right">
|
||||
{result.lineNumber}
|
||||
</span>
|
||||
<span className="truncate text-foreground">
|
||||
{result.lineContent.substring(0, result.matchStart)}
|
||||
<span className="bg-yellow-500/30 text-yellow-200">
|
||||
{result.lineContent.substring(result.matchStart, result.matchEnd)}
|
||||
</span>
|
||||
{result.lineContent.substring(result.matchEnd)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
172
src/components/SettingsDialog.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { open as openDialog } from "@tauri-apps/plugin-dialog";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useStore, actions } from "@/lib/store";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
interface SettingsDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
|
||||
const { settings } = useStore();
|
||||
|
||||
const handlePickCustomCSS = async () => {
|
||||
const selected = await openDialog({
|
||||
multiple: false,
|
||||
filters: [{ name: "CSS Files", extensions: ["css"] }],
|
||||
});
|
||||
if (selected) {
|
||||
actions.updateSettings({ customCssPath: selected as string });
|
||||
}
|
||||
};
|
||||
|
||||
const handleClearCustomCSS = () => {
|
||||
actions.updateSettings({ customCssPath: null });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[450px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Settings</DialogTitle>
|
||||
<DialogDescription>Configure the editor to your preferences.</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Font Size</label>
|
||||
<p className="text-xs text-muted-foreground">{settings.fontSize}px</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => actions.updateSettings({ fontSize: Math.max(10, settings.fontSize - 1) })}
|
||||
>
|
||||
-
|
||||
</Button>
|
||||
<span className="w-8 text-center text-sm">{settings.fontSize}</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => actions.updateSettings({ fontSize: Math.min(32, settings.fontSize + 1) })}
|
||||
>
|
||||
+
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Tab Size</label>
|
||||
<p className="text-xs text-muted-foreground">Spaces per tab</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{[2, 4, 8].map((size) => (
|
||||
<Button
|
||||
key={size}
|
||||
variant={settings.tabSize === size ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => actions.updateSettings({ tabSize: size })}
|
||||
>
|
||||
{size}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Word Wrap</label>
|
||||
<p className="text-xs text-muted-foreground">Wrap long lines</p>
|
||||
</div>
|
||||
<Button
|
||||
variant={settings.wordWrap ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => actions.updateSettings({ wordWrap: !settings.wordWrap })}
|
||||
>
|
||||
{settings.wordWrap ? "On" : "Off"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Theme</label>
|
||||
<p className="text-xs text-muted-foreground">Editor color scheme</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant={settings.theme === "dark" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => actions.updateSettings({ theme: "dark" })}
|
||||
>
|
||||
Dark
|
||||
</Button>
|
||||
<Button
|
||||
variant={settings.theme === "light" ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => actions.updateSettings({ theme: "light" })}
|
||||
>
|
||||
Light
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Auto Save</label>
|
||||
<p className="text-xs text-muted-foreground">Save files automatically</p>
|
||||
</div>
|
||||
<Button
|
||||
variant={settings.autoSave ? "default" : "outline"}
|
||||
size="sm"
|
||||
onClick={() => actions.updateSettings({ autoSave: !settings.autoSave })}
|
||||
>
|
||||
{settings.autoSave ? "On" : "Off"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Custom CSS</label>
|
||||
<p className="text-xs text-muted-foreground truncate max-w-[200px]">
|
||||
{settings.customCssPath
|
||||
? settings.customCssPath.split("/").pop()
|
||||
: "No custom theme loaded"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<Button variant="outline" size="sm" onClick={handlePickCustomCSS}>
|
||||
Browse
|
||||
</Button>
|
||||
{settings.customCssPath && (
|
||||
<Button variant="outline" size="sm" onClick={handleClearCustomCSS}>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
50
src/components/StatusBar.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { useStore, actions } from "@/lib/store";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { getLanguageDisplayName, supportedLanguages } from "@/lib/languageDetect";
|
||||
|
||||
export function StatusBar() {
|
||||
const store = useStore();
|
||||
const activeTab = store.tabs.find((t) => t.path === store.activeTabPath);
|
||||
const folderName = store.workspacePath?.split("/").pop() ?? store.workspacePath?.split("\\").pop() ?? "No folder";
|
||||
|
||||
return (
|
||||
<div className="flex h-[22px] items-center justify-between bg-[#007acc] px-2 text-[11px] text-white select-none shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<span>{folderName}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{activeTab && (
|
||||
<>
|
||||
<Select
|
||||
value={activeTab.language}
|
||||
onValueChange={(lang) => actions.updateTabLanguage(activeTab.path, lang)}
|
||||
>
|
||||
<SelectTrigger className="h-[18px] border-0 bg-transparent text-white text-[11px] gap-1 px-1 hover:bg-white/20 focus:ring-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{supportedLanguages.map((lang) => (
|
||||
<SelectItem key={lang} value={lang}>
|
||||
{getLanguageDisplayName(lang)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span>
|
||||
Ln {store.cursorLine}, Col {store.cursorCol}
|
||||
</span>
|
||||
<span>Spaces: {store.settings.tabSize}</span>
|
||||
<span>UTF-8</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
160
src/components/Terminal.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
import { useEffect, useRef, useCallback } from "react";
|
||||
import { Terminal as XTerminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { Command } from "@tauri-apps/plugin-shell";
|
||||
import { useStore } from "@/lib/store";
|
||||
|
||||
export function Terminal() {
|
||||
const termRef = useRef<HTMLDivElement>(null);
|
||||
const xtermRef = useRef<XTerminal | null>(null);
|
||||
const fitAddonRef = useRef<FitAddon | null>(null);
|
||||
const { workspacePath, settings } = useStore();
|
||||
const childRef = useRef<any>(null);
|
||||
const inputBufferRef = useRef("");
|
||||
|
||||
const initTerminal = useCallback(async () => {
|
||||
if (!termRef.current || xtermRef.current) return;
|
||||
|
||||
const xterm = new XTerminal({
|
||||
cursorBlink: true,
|
||||
fontSize: settings.fontSize - 1,
|
||||
fontFamily: settings.fontFamily,
|
||||
theme: {
|
||||
background: "#1e1e1e",
|
||||
foreground: "#cccccc",
|
||||
cursor: "#ffffff",
|
||||
selectionBackground: "#264f78",
|
||||
black: "#000000",
|
||||
red: "#cd3131",
|
||||
green: "#0dbc79",
|
||||
yellow: "#e5e510",
|
||||
blue: "#2472c8",
|
||||
magenta: "#bc3fbc",
|
||||
cyan: "#11a8cd",
|
||||
white: "#e5e5e5",
|
||||
},
|
||||
allowProposedApi: true,
|
||||
});
|
||||
|
||||
const fitAddon = new FitAddon();
|
||||
xterm.loadAddon(fitAddon);
|
||||
xterm.loadAddon(new WebLinksAddon());
|
||||
|
||||
xterm.open(termRef.current);
|
||||
fitAddon.fit();
|
||||
|
||||
xtermRef.current = xterm;
|
||||
fitAddonRef.current = fitAddon;
|
||||
|
||||
// Try to spawn a shell using Tauri shell plugin
|
||||
try {
|
||||
const shell = detectShell();
|
||||
const cmd = Command.create("exec-sh", ["-c", shell], {
|
||||
cwd: workspacePath ?? undefined,
|
||||
});
|
||||
|
||||
cmd.on("close", () => {
|
||||
xterm.writeln("\r\n[Process exited]");
|
||||
});
|
||||
|
||||
cmd.stdout.on("data", (data: string) => {
|
||||
xterm.write(data);
|
||||
});
|
||||
|
||||
cmd.stderr.on("data", (data: string) => {
|
||||
xterm.write(data);
|
||||
});
|
||||
|
||||
const child = await cmd.spawn();
|
||||
childRef.current = child;
|
||||
|
||||
xterm.onData((data: string) => {
|
||||
child.write(data);
|
||||
});
|
||||
} catch (err) {
|
||||
xterm.writeln(`Terminal: Could not spawn shell. Error: ${err}`);
|
||||
xterm.writeln("Make sure shell permissions are configured in Tauri.");
|
||||
xterm.writeln("");
|
||||
|
||||
// Fallback: simple command executor
|
||||
xterm.write("$ ");
|
||||
xterm.onData((data: string) => {
|
||||
if (data === "\r") {
|
||||
const cmd = inputBufferRef.current.trim();
|
||||
inputBufferRef.current = "";
|
||||
xterm.writeln("");
|
||||
if (cmd) {
|
||||
executeCommand(xterm, cmd);
|
||||
} else {
|
||||
xterm.write("$ ");
|
||||
}
|
||||
} else if (data === "\x7f") {
|
||||
// Backspace
|
||||
if (inputBufferRef.current.length > 0) {
|
||||
inputBufferRef.current = inputBufferRef.current.slice(0, -1);
|
||||
xterm.write("\b \b");
|
||||
}
|
||||
} else if (data >= " ") {
|
||||
inputBufferRef.current += data;
|
||||
xterm.write(data);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle resize
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
fitAddon.fit();
|
||||
});
|
||||
resizeObserver.observe(termRef.current);
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [workspacePath, settings.fontSize, settings.fontFamily]);
|
||||
|
||||
useEffect(() => {
|
||||
initTerminal();
|
||||
return () => {
|
||||
if (childRef.current) {
|
||||
childRef.current.kill().catch(() => {});
|
||||
}
|
||||
if (xtermRef.current) {
|
||||
xtermRef.current.dispose();
|
||||
xtermRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [initTerminal]);
|
||||
|
||||
// Refit on visibility change
|
||||
useEffect(() => {
|
||||
if (fitAddonRef.current) {
|
||||
setTimeout(() => fitAddonRef.current?.fit(), 50);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="h-full w-full bg-[#1e1e1e] overflow-hidden">
|
||||
<div ref={termRef} className="h-full w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function detectShell(): string {
|
||||
// Check common shells
|
||||
const shell = typeof navigator !== "undefined" ? "bash" : "bash";
|
||||
return shell;
|
||||
}
|
||||
|
||||
async function executeCommand(xterm: XTerminal, cmd: string) {
|
||||
try {
|
||||
const command = Command.create("exec-sh", ["-c", cmd]);
|
||||
const output = await command.execute();
|
||||
if (output.stdout) xterm.write(output.stdout.replace(/\n/g, "\r\n"));
|
||||
if (output.stderr) xterm.write(output.stderr.replace(/\n/g, "\r\n"));
|
||||
} catch (err) {
|
||||
xterm.writeln(`Error: ${err}`);
|
||||
}
|
||||
xterm.write("$ ");
|
||||
}
|
||||
91
src/components/WelcomeTab.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import { FolderOpen, Keyboard } from "lucide-react";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useStore, actions } from "@/lib/store";
|
||||
|
||||
const shortcuts = [
|
||||
{ keys: "Ctrl+P", action: "Quick Open File" },
|
||||
{ keys: "Ctrl+Shift+P", action: "Command Palette" },
|
||||
{ keys: "Ctrl+S", action: "Save File" },
|
||||
{ keys: "Ctrl+Shift+S", action: "Save All" },
|
||||
{ keys: "Ctrl+W", action: "Close Tab" },
|
||||
{ keys: "Ctrl+B", action: "Toggle Sidebar" },
|
||||
{ keys: "Ctrl+Shift+F", action: "Search in Files" },
|
||||
{ keys: "Ctrl+G", action: "Go to Line" },
|
||||
{ keys: "Ctrl+`", action: "Toggle Terminal" },
|
||||
{ keys: "Ctrl+\\", action: "Split Editor" },
|
||||
{ keys: "Ctrl+F", action: "Find in File" },
|
||||
];
|
||||
|
||||
export function WelcomeTab() {
|
||||
const { recentWorkspaces } = useStore();
|
||||
|
||||
const handleOpenFolder = async () => {
|
||||
const selected = await open({ directory: true, multiple: false });
|
||||
if (selected) {
|
||||
actions.setWorkspace(selected as string);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenRecent = (path: string) => {
|
||||
actions.setWorkspace(path);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center text-foreground overflow-auto">
|
||||
<div className="max-w-lg w-full px-8 py-12 space-y-8">
|
||||
<div className="text-center space-y-2">
|
||||
<h1 className="text-2xl font-light">Lunar Code</h1>
|
||||
<p className="text-sm text-muted-foreground">Lightweight. Fast. Focused.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Button variant="secondary" size="default" onClick={handleOpenFolder} className="gap-2">
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
Open Folder
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{recentWorkspaces.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Recent
|
||||
</h2>
|
||||
<div className="space-y-0.5">
|
||||
{recentWorkspaces.slice(0, 5).map((path) => {
|
||||
const name = path.split("/").pop() ?? path.split("\\").pop() ?? path;
|
||||
return (
|
||||
<button
|
||||
key={path}
|
||||
className="flex flex-col w-full text-left px-3 py-1.5 rounded-sm hover:bg-accent transition-colors"
|
||||
onClick={() => handleOpenRecent(path)}
|
||||
>
|
||||
<span className="text-sm text-primary">{name}</span>
|
||||
<span className="text-xs text-muted-foreground truncate">{path}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground flex items-center gap-1">
|
||||
<Keyboard className="h-3 w-3" />
|
||||
Keyboard Shortcuts
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-0.5">
|
||||
{shortcuts.map((s) => (
|
||||
<div key={s.keys} className="flex items-center justify-between py-0.5">
|
||||
<span className="text-xs text-muted-foreground">{s.action}</span>
|
||||
<kbd className="ml-2 px-1.5 py-0.5 text-[10px] font-mono bg-secondary rounded text-secondary-foreground">
|
||||
{s.keys}
|
||||
</kbd>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-sm px-2 py-0.5 text-xs font-medium transition-colors focus:outline-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary/20 text-primary-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground",
|
||||
destructive: "bg-destructive/20 text-destructive-foreground",
|
||||
outline: "border border-border text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
48
src/components/ui/button.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline: "border border-border bg-transparent hover:bg-accent hover:text-accent-foreground",
|
||||
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-8 px-3 py-1",
|
||||
sm: "h-7 px-2 text-xs",
|
||||
lg: "h-9 px-4",
|
||||
icon: "h-7 w-7",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean;
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||
);
|
||||
}
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
59
src/components/ui/context-menu.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import * as React from "react";
|
||||
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ContextMenu = ContextMenuPrimitive.Root;
|
||||
const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
|
||||
|
||||
const ContextMenuContent = React.forwardRef<
|
||||
React.ComponentRef<typeof ContextMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-sm border border-border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in-80",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
));
|
||||
ContextMenuContent.displayName = ContextMenuPrimitive.Content.displayName;
|
||||
|
||||
const ContextMenuItem = React.forwardRef<
|
||||
React.ComponentRef<typeof ContextMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Item> & { inset?: boolean }
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuItem.displayName = ContextMenuPrimitive.Item.displayName;
|
||||
|
||||
const ContextMenuSeparator = React.forwardRef<
|
||||
React.ComponentRef<typeof ContextMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof ContextMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
ContextMenuSeparator.displayName = ContextMenuPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
};
|
||||
95
src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Dialog = DialogPrimitive.Root;
|
||||
const DialogTrigger = DialogPrimitive.Trigger;
|
||||
const DialogPortal = DialogPrimitive.Portal;
|
||||
const DialogClose = DialogPrimitive.Close;
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border border-border bg-card p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
));
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col space-y-1.5 text-center sm:text-left", className)} {...props} />
|
||||
);
|
||||
DialogHeader.displayName = "DialogHeader";
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn("flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2", className)} {...props} />
|
||||
);
|
||||
DialogFooter.displayName = "DialogFooter";
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName;
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ComponentRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName;
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
};
|
||||
101
src/components/ui/dropdown-menu.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import * as React from "react";
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const DropdownMenu = DropdownMenuPrimitive.Root;
|
||||
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
|
||||
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
|
||||
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-sm border border-border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & { inset?: boolean }
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & { inset?: boolean }
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
));
|
||||
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ComponentRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-sm border border-border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
));
|
||||
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
};
|
||||
21
src/components/ui/input.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, React.InputHTMLAttributes<HTMLInputElement>>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-8 w-full rounded-sm border border-input bg-muted px-3 py-1 text-sm text-foreground shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
Input.displayName = "Input";
|
||||
|
||||
export { Input };
|
||||
142
src/components/ui/menubar.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import * as React from "react";
|
||||
import * as MenubarPrimitive from "@radix-ui/react-menubar";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const MenubarMenu = MenubarPrimitive.Menu;
|
||||
const MenubarGroup = MenubarPrimitive.Group;
|
||||
const MenubarSub = MenubarPrimitive.Sub;
|
||||
const MenubarRadioGroup = MenubarPrimitive.RadioGroup;
|
||||
|
||||
const Menubar = React.forwardRef<
|
||||
React.ComponentRef<typeof MenubarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-8 items-center space-x-1 bg-card px-2 text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Menubar.displayName = MenubarPrimitive.Root.displayName;
|
||||
|
||||
const MenubarTrigger = React.forwardRef<
|
||||
React.ComponentRef<typeof MenubarPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1 text-sm font-normal outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarTrigger.displayName = MenubarPrimitive.Trigger.displayName;
|
||||
|
||||
const MenubarContent = React.forwardRef<
|
||||
React.ComponentRef<typeof MenubarPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Content>
|
||||
>(({ className, align = "start", alignOffset = -4, sideOffset = 8, ...props }, ref) => (
|
||||
<MenubarPrimitive.Portal>
|
||||
<MenubarPrimitive.Content
|
||||
ref={ref}
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 min-w-[12rem] overflow-hidden rounded-sm border border-border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenubarPrimitive.Portal>
|
||||
));
|
||||
MenubarContent.displayName = MenubarPrimitive.Content.displayName;
|
||||
|
||||
const MenubarItem = React.forwardRef<
|
||||
React.ComponentRef<typeof MenubarPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Item> & { inset?: boolean }
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<MenubarPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarItem.displayName = MenubarPrimitive.Item.displayName;
|
||||
|
||||
const MenubarSeparator = React.forwardRef<
|
||||
React.ComponentRef<typeof MenubarPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
MenubarSeparator.displayName = MenubarPrimitive.Separator.displayName;
|
||||
|
||||
const MenubarShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (
|
||||
<span className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)} {...props} />
|
||||
);
|
||||
MenubarShortcut.displayName = "MenubarShortcut";
|
||||
|
||||
const MenubarSubTrigger = React.forwardRef<
|
||||
React.ComponentRef<typeof MenubarPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubTrigger> & { inset?: boolean }
|
||||
>(({ className, inset, children, ...props }, ref) => (
|
||||
<MenubarPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<span className="ml-auto">›</span>
|
||||
</MenubarPrimitive.SubTrigger>
|
||||
));
|
||||
MenubarSubTrigger.displayName = MenubarPrimitive.SubTrigger.displayName;
|
||||
|
||||
const MenubarSubContent = React.forwardRef<
|
||||
React.ComponentRef<typeof MenubarPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof MenubarPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<MenubarPrimitive.Portal>
|
||||
<MenubarPrimitive.SubContent
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 min-w-[8rem] overflow-hidden rounded-sm border border-border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenubarPrimitive.Portal>
|
||||
));
|
||||
MenubarSubContent.displayName = MenubarPrimitive.SubContent.displayName;
|
||||
|
||||
export {
|
||||
Menubar,
|
||||
MenubarMenu,
|
||||
MenubarTrigger,
|
||||
MenubarContent,
|
||||
MenubarItem,
|
||||
MenubarSeparator,
|
||||
MenubarShortcut,
|
||||
MenubarGroup,
|
||||
MenubarSub,
|
||||
MenubarSubTrigger,
|
||||
MenubarSubContent,
|
||||
MenubarRadioGroup,
|
||||
};
|
||||
43
src/components/ui/scroll-area.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import * as React from "react";
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ComponentRef<typeof ScrollAreaPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
));
|
||||
ScrollArea.displayName = ScrollAreaPrimitive.Root.displayName;
|
||||
|
||||
const ScrollBar = React.forwardRef<
|
||||
React.ComponentRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
>(({ className, orientation = "vertical", ...props }, ref) => (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
ref={ref}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none select-none transition-colors",
|
||||
orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent p-[1px]",
|
||||
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
));
|
||||
ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName;
|
||||
|
||||
export { ScrollArea, ScrollBar };
|
||||
82
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ComponentRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-7 items-center justify-between rounded-sm border border-input bg-transparent px-2 py-1 text-xs ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="ml-1 h-3 w-3 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
));
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ComponentRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-sm border border-border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
position === "popper" && "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" && "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ComponentRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
));
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
};
|
||||
23
src/components/ui/separator.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import * as React from "react";
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ComponentRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName;
|
||||
|
||||
export { Separator };
|
||||
27
src/components/ui/tooltip.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider;
|
||||
const Tooltip = TooltipPrimitive.Root;
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger;
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ComponentRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 overflow-hidden rounded-sm bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-md animate-in fade-in-0 zoom-in-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipPrimitive.Portal>
|
||||
));
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
43
src/hooks/useAutoSave.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useStore, actions } from "@/lib/store";
|
||||
|
||||
export function useAutoSave() {
|
||||
const { settings, tabs } = useStore();
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const prevContentRef = useRef<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!settings.autoSave) {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const contentKey = tabs.map((t) => t.path + ":" + t.content.length).join("|");
|
||||
if (contentKey === prevContentRef.current) return;
|
||||
prevContentRef.current = contentKey;
|
||||
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
|
||||
timerRef.current = setTimeout(async () => {
|
||||
const currentState = actions.getState();
|
||||
for (const tab of currentState.tabs) {
|
||||
if (tab.content !== tab.savedContent) {
|
||||
try {
|
||||
await invoke("write_file", { path: tab.path, contents: tab.content });
|
||||
actions.markSaved(tab.path);
|
||||
} catch (err) {
|
||||
console.error("Auto-save failed for", tab.name, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, 1500);
|
||||
|
||||
return () => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current);
|
||||
};
|
||||
}, [settings.autoSave, tabs]);
|
||||
}
|
||||
119
src/hooks/useKeyboardShortcuts.ts
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useEffect } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { actions } from "@/lib/store";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface ShortcutHandlers {
|
||||
onOpenCommandPalette: () => void;
|
||||
onOpenQuickOpen: () => void;
|
||||
onToggleTerminal: () => void;
|
||||
onSearchInFiles: () => void;
|
||||
onGoToLine: () => void;
|
||||
onSplitEditor: () => void;
|
||||
}
|
||||
|
||||
export function useKeyboardShortcuts({
|
||||
onOpenCommandPalette,
|
||||
onOpenQuickOpen,
|
||||
onToggleTerminal,
|
||||
onSearchInFiles,
|
||||
onGoToLine,
|
||||
onSplitEditor,
|
||||
}: ShortcutHandlers) {
|
||||
useEffect(() => {
|
||||
const handler = async (e: KeyboardEvent) => {
|
||||
const ctrl = e.ctrlKey || e.metaKey;
|
||||
|
||||
if (ctrl && e.shiftKey && e.key === "P") {
|
||||
e.preventDefault();
|
||||
onOpenCommandPalette();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctrl && e.key === "p") {
|
||||
e.preventDefault();
|
||||
onOpenQuickOpen();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctrl && e.key === "b") {
|
||||
e.preventDefault();
|
||||
actions.toggleSidebar();
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctrl && e.key === "s") {
|
||||
e.preventDefault();
|
||||
const state = actions.getState();
|
||||
const activeTab = state.tabs.find((t) => t.path === state.activeTabPath);
|
||||
if (activeTab) {
|
||||
try {
|
||||
await invoke("write_file", { path: activeTab.path, contents: activeTab.content });
|
||||
actions.markSaved(activeTab.path);
|
||||
toast.success("Saved");
|
||||
} catch (err) {
|
||||
toast.error("Failed to save: " + String(err));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctrl && e.shiftKey && e.key === "S") {
|
||||
e.preventDefault();
|
||||
const state = actions.getState();
|
||||
for (const tab of state.tabs) {
|
||||
if (tab.content !== tab.savedContent) {
|
||||
try {
|
||||
await invoke("write_file", { path: tab.path, contents: tab.content });
|
||||
actions.markSaved(tab.path);
|
||||
} catch (err) {
|
||||
toast.error(`Failed to save ${tab.name}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
toast.success("All saved");
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctrl && e.key === "w") {
|
||||
e.preventDefault();
|
||||
const state = actions.getState();
|
||||
if (state.activeTabPath) {
|
||||
actions.closeTab(state.activeTabPath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+` - Toggle Terminal
|
||||
if (ctrl && e.key === "`") {
|
||||
e.preventDefault();
|
||||
onToggleTerminal();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Shift+F - Search in Files
|
||||
if (ctrl && e.shiftKey && e.key === "F") {
|
||||
e.preventDefault();
|
||||
onSearchInFiles();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+G - Go to Line
|
||||
if (ctrl && e.key === "g") {
|
||||
e.preventDefault();
|
||||
onGoToLine();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+\ - Split Editor
|
||||
if (ctrl && e.key === "\\") {
|
||||
e.preventDefault();
|
||||
onSplitEditor();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handler);
|
||||
return () => window.removeEventListener("keydown", handler);
|
||||
}, [onOpenCommandPalette, onOpenQuickOpen, onToggleTerminal, onSearchInFiles, onGoToLine, onSplitEditor]);
|
||||
}
|
||||
116
src/index.css
Normal file
@@ -0,0 +1,116 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-background: #1e1e1e;
|
||||
--color-foreground: #cccccc;
|
||||
--color-card: #252526;
|
||||
--color-card-foreground: #cccccc;
|
||||
--color-popover: #252526;
|
||||
--color-popover-foreground: #cccccc;
|
||||
--color-primary: #007acc;
|
||||
--color-primary-foreground: #ffffff;
|
||||
--color-secondary: #3c3c3c;
|
||||
--color-secondary-foreground: #cccccc;
|
||||
--color-muted: #2d2d2d;
|
||||
--color-muted-foreground: #969696;
|
||||
--color-accent: #04395e;
|
||||
--color-accent-foreground: #ffffff;
|
||||
--color-destructive: #f44747;
|
||||
--color-destructive-foreground: #ffffff;
|
||||
--color-border: #3c3c3c;
|
||||
--color-input: #3c3c3c;
|
||||
--color-ring: #007acc;
|
||||
--radius: 0.25rem;
|
||||
|
||||
--color-sidebar-background: #252526;
|
||||
--color-sidebar-foreground: #cccccc;
|
||||
--color-sidebar-primary: #007acc;
|
||||
--color-sidebar-primary-foreground: #ffffff;
|
||||
--color-sidebar-accent: #37373d;
|
||||
--color-sidebar-accent-foreground: #cccccc;
|
||||
--color-sidebar-border: #3c3c3c;
|
||||
--color-sidebar-ring: #007acc;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
src: local('JetBrains Mono'), local('JetBrainsMono');
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background-color: var(--color-background);
|
||||
color: var(--color-foreground);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* CodeMirror overrides - do not let Tailwind preflight break it */
|
||||
.cm-editor {
|
||||
height: 100%;
|
||||
font-family: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.cm-editor .cm-scroller {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.cm-editor .cm-content {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.cm-editor.cm-focused {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
/* Search panel styling */
|
||||
.cm-editor .cm-panels {
|
||||
background-color: #252526;
|
||||
border-bottom: 1px solid #3c3c3c;
|
||||
}
|
||||
|
||||
.cm-editor .cm-panels input {
|
||||
background-color: #3c3c3c;
|
||||
color: #cccccc;
|
||||
border: 1px solid #3c3c3c;
|
||||
border-radius: 2px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.cm-editor .cm-panels button {
|
||||
color: #cccccc;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cm-editor .cm-panels button:hover {
|
||||
background-color: #3c3c3c;
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #424242;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #4f4f4f;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
171
src/lib/languageDetect.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import { StreamLanguage } from "@codemirror/language";
|
||||
|
||||
const languageLoaders: Record<string, () => Promise<Extension>> = {
|
||||
javascript: async () => {
|
||||
const { javascript } = await import("@codemirror/lang-javascript");
|
||||
return javascript({ jsx: true });
|
||||
},
|
||||
typescript: async () => {
|
||||
const { javascript } = await import("@codemirror/lang-javascript");
|
||||
return javascript({ jsx: true, typescript: true });
|
||||
},
|
||||
python: async () => {
|
||||
const { python } = await import("@codemirror/lang-python");
|
||||
return python();
|
||||
},
|
||||
rust: async () => {
|
||||
const { rust } = await import("@codemirror/lang-rust");
|
||||
return rust();
|
||||
},
|
||||
cpp: async () => {
|
||||
const { cpp } = await import("@codemirror/lang-cpp");
|
||||
return cpp();
|
||||
},
|
||||
java: async () => {
|
||||
const { java } = await import("@codemirror/lang-java");
|
||||
return java();
|
||||
},
|
||||
go: async () => {
|
||||
const { go } = await import("@codemirror/lang-go");
|
||||
return go();
|
||||
},
|
||||
php: async () => {
|
||||
const { php } = await import("@codemirror/lang-php");
|
||||
return php();
|
||||
},
|
||||
sql: async () => {
|
||||
const { sql } = await import("@codemirror/lang-sql");
|
||||
return sql();
|
||||
},
|
||||
html: async () => {
|
||||
const { html } = await import("@codemirror/lang-html");
|
||||
return html();
|
||||
},
|
||||
css: async () => {
|
||||
const { css } = await import("@codemirror/lang-css");
|
||||
return css();
|
||||
},
|
||||
json: async () => {
|
||||
const { json } = await import("@codemirror/lang-json");
|
||||
return json();
|
||||
},
|
||||
xml: async () => {
|
||||
const { xml } = await import("@codemirror/lang-xml");
|
||||
return xml();
|
||||
},
|
||||
markdown: async () => {
|
||||
const { markdown } = await import("@codemirror/lang-markdown");
|
||||
return markdown();
|
||||
},
|
||||
yaml: async () => {
|
||||
const { yaml } = await import("@codemirror/lang-yaml");
|
||||
return yaml();
|
||||
},
|
||||
shell: async () => {
|
||||
const { shell } = await import("@codemirror/legacy-modes/mode/shell");
|
||||
return StreamLanguage.define(shell);
|
||||
},
|
||||
toml: async () => {
|
||||
const { toml } = await import("@codemirror/legacy-modes/mode/toml");
|
||||
return StreamLanguage.define(toml);
|
||||
},
|
||||
dockerfile: async () => {
|
||||
const { dockerFile } = await import("@codemirror/legacy-modes/mode/dockerfile");
|
||||
return StreamLanguage.define(dockerFile);
|
||||
},
|
||||
};
|
||||
|
||||
const extToLanguage: Record<string, string> = {
|
||||
js: "javascript",
|
||||
mjs: "javascript",
|
||||
cjs: "javascript",
|
||||
jsx: "javascript",
|
||||
ts: "typescript",
|
||||
tsx: "typescript",
|
||||
mts: "typescript",
|
||||
cts: "typescript",
|
||||
py: "python",
|
||||
pyw: "python",
|
||||
rs: "rust",
|
||||
c: "cpp",
|
||||
h: "cpp",
|
||||
cpp: "cpp",
|
||||
cxx: "cpp",
|
||||
cc: "cpp",
|
||||
hpp: "cpp",
|
||||
hxx: "cpp",
|
||||
java: "java",
|
||||
go: "go",
|
||||
php: "php",
|
||||
sql: "sql",
|
||||
html: "html",
|
||||
htm: "html",
|
||||
svelte: "html",
|
||||
vue: "html",
|
||||
css: "css",
|
||||
scss: "css",
|
||||
less: "css",
|
||||
json: "json",
|
||||
jsonc: "json",
|
||||
xml: "xml",
|
||||
svg: "xml",
|
||||
md: "markdown",
|
||||
mdx: "markdown",
|
||||
markdown: "markdown",
|
||||
yaml: "yaml",
|
||||
yml: "yaml",
|
||||
sh: "shell",
|
||||
bash: "shell",
|
||||
zsh: "shell",
|
||||
fish: "shell",
|
||||
toml: "toml",
|
||||
dockerfile: "dockerfile",
|
||||
};
|
||||
|
||||
const filenameToLanguage: Record<string, string> = {
|
||||
Dockerfile: "dockerfile",
|
||||
Makefile: "shell",
|
||||
Jenkinsfile: "java",
|
||||
};
|
||||
|
||||
export function detectLanguage(filename: string): string {
|
||||
if (filenameToLanguage[filename]) {
|
||||
return filenameToLanguage[filename];
|
||||
}
|
||||
const ext = filename.split(".").pop()?.toLowerCase() ?? "";
|
||||
return extToLanguage[ext] ?? "plaintext";
|
||||
}
|
||||
|
||||
export async function getLanguageExtension(language: string): Promise<Extension | null> {
|
||||
const loader = languageLoaders[language];
|
||||
if (!loader) return null;
|
||||
return loader();
|
||||
}
|
||||
|
||||
export function getLanguageDisplayName(language: string): string {
|
||||
const names: Record<string, string> = {
|
||||
javascript: "JavaScript",
|
||||
typescript: "TypeScript",
|
||||
python: "Python",
|
||||
rust: "Rust",
|
||||
cpp: "C/C++",
|
||||
java: "Java",
|
||||
go: "Go",
|
||||
php: "PHP",
|
||||
sql: "SQL",
|
||||
html: "HTML",
|
||||
css: "CSS",
|
||||
json: "JSON",
|
||||
xml: "XML",
|
||||
markdown: "Markdown",
|
||||
yaml: "YAML",
|
||||
shell: "Shell",
|
||||
toml: "TOML",
|
||||
dockerfile: "Dockerfile",
|
||||
plaintext: "Plain Text",
|
||||
};
|
||||
return names[language] ?? language;
|
||||
}
|
||||
|
||||
export const supportedLanguages = Object.keys(languageLoaders).concat("plaintext");
|
||||
268
src/lib/store.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import { useSyncExternalStore, useCallback } from "react";
|
||||
|
||||
export interface EditorTab {
|
||||
path: string;
|
||||
name: string;
|
||||
content: string;
|
||||
savedContent: string;
|
||||
language: string;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
filePath: string;
|
||||
fileName: string;
|
||||
lineNumber: number;
|
||||
lineContent: string;
|
||||
matchStart: number;
|
||||
matchEnd: number;
|
||||
}
|
||||
|
||||
export interface EditorSettings {
|
||||
fontSize: number;
|
||||
fontFamily: string;
|
||||
tabSize: number;
|
||||
wordWrap: boolean;
|
||||
autoSave: boolean;
|
||||
theme: "dark" | "light";
|
||||
customCssPath: string | null;
|
||||
}
|
||||
|
||||
interface EditorStore {
|
||||
tabs: EditorTab[];
|
||||
activeTabPath: string | null;
|
||||
workspacePath: string | null;
|
||||
sidebarVisible: boolean;
|
||||
terminalVisible: boolean;
|
||||
sidebarView: "files" | "search";
|
||||
searchQuery: string;
|
||||
searchResults: SearchResult[];
|
||||
searchCaseSensitive: boolean;
|
||||
searchRegex: boolean;
|
||||
splitEditorPath: string | null;
|
||||
recentWorkspaces: string[];
|
||||
fileTreeFilter: string;
|
||||
cursorLine: number;
|
||||
cursorCol: number;
|
||||
settings: EditorSettings;
|
||||
}
|
||||
|
||||
const defaultSettings: EditorSettings = {
|
||||
fontSize: 14,
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', monospace",
|
||||
tabSize: 2,
|
||||
wordWrap: false,
|
||||
autoSave: false,
|
||||
theme: "dark",
|
||||
customCssPath: null,
|
||||
};
|
||||
|
||||
function loadSettings(): EditorSettings {
|
||||
try {
|
||||
const stored = localStorage.getItem("editor-settings");
|
||||
if (stored) return { ...defaultSettings, ...JSON.parse(stored) };
|
||||
} catch { /* ignore */ }
|
||||
return defaultSettings;
|
||||
}
|
||||
|
||||
function loadRecentWorkspaces(): string[] {
|
||||
try {
|
||||
const stored = localStorage.getItem("recent-workspaces");
|
||||
if (stored) return JSON.parse(stored);
|
||||
} catch { /* ignore */ }
|
||||
return [];
|
||||
}
|
||||
|
||||
let state: EditorStore = {
|
||||
tabs: [],
|
||||
activeTabPath: null,
|
||||
workspacePath: null,
|
||||
sidebarVisible: true,
|
||||
terminalVisible: false,
|
||||
sidebarView: "files",
|
||||
searchQuery: "",
|
||||
searchResults: [],
|
||||
searchCaseSensitive: false,
|
||||
searchRegex: false,
|
||||
splitEditorPath: null,
|
||||
recentWorkspaces: loadRecentWorkspaces(),
|
||||
fileTreeFilter: "",
|
||||
cursorLine: 1,
|
||||
cursorCol: 1,
|
||||
settings: loadSettings(),
|
||||
};
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function emit() {
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
function getSnapshot() {
|
||||
return state;
|
||||
}
|
||||
|
||||
function subscribe(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
}
|
||||
|
||||
export function useStore() {
|
||||
return useSyncExternalStore(subscribe, getSnapshot);
|
||||
}
|
||||
|
||||
export function useStoreSelector<T>(selector: (s: EditorStore) => T): T {
|
||||
const select = useCallback(() => selector(getSnapshot()), [selector]);
|
||||
return useSyncExternalStore(subscribe, select);
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
openFile(path: string, name: string, content: string, language: string) {
|
||||
const existing = state.tabs.find((t) => t.path === path);
|
||||
if (existing) {
|
||||
state = { ...state, activeTabPath: path };
|
||||
} else {
|
||||
state = {
|
||||
...state,
|
||||
tabs: [...state.tabs, { path, name, content, savedContent: content, language }],
|
||||
activeTabPath: path,
|
||||
};
|
||||
}
|
||||
emit();
|
||||
},
|
||||
|
||||
closeTab(path: string) {
|
||||
const tabs = state.tabs.filter((t) => t.path !== path);
|
||||
let activeTabPath = state.activeTabPath;
|
||||
if (activeTabPath === path) {
|
||||
const idx = state.tabs.findIndex((t) => t.path === path);
|
||||
activeTabPath = tabs[Math.min(idx, tabs.length - 1)]?.path ?? null;
|
||||
}
|
||||
// Also close split if it was showing this file
|
||||
let splitEditorPath = state.splitEditorPath;
|
||||
if (splitEditorPath === path) {
|
||||
splitEditorPath = null;
|
||||
}
|
||||
state = { ...state, tabs, activeTabPath, splitEditorPath };
|
||||
emit();
|
||||
},
|
||||
|
||||
setActiveTab(path: string) {
|
||||
state = { ...state, activeTabPath: path };
|
||||
emit();
|
||||
},
|
||||
|
||||
updateContent(path: string, content: string) {
|
||||
state = {
|
||||
...state,
|
||||
tabs: state.tabs.map((t) => (t.path === path ? { ...t, content } : t)),
|
||||
};
|
||||
emit();
|
||||
},
|
||||
|
||||
markSaved(path: string) {
|
||||
state = {
|
||||
...state,
|
||||
tabs: state.tabs.map((t) =>
|
||||
t.path === path ? { ...t, savedContent: t.content } : t
|
||||
),
|
||||
};
|
||||
emit();
|
||||
},
|
||||
|
||||
setWorkspace(path: string | null) {
|
||||
state = { ...state, workspacePath: path };
|
||||
if (path) {
|
||||
const recents = [path, ...state.recentWorkspaces.filter((w) => w !== path)].slice(0, 10);
|
||||
state = { ...state, recentWorkspaces: recents };
|
||||
localStorage.setItem("recent-workspaces", JSON.stringify(recents));
|
||||
}
|
||||
emit();
|
||||
},
|
||||
|
||||
toggleSidebar() {
|
||||
state = { ...state, sidebarVisible: !state.sidebarVisible };
|
||||
emit();
|
||||
},
|
||||
|
||||
toggleTerminal() {
|
||||
state = { ...state, terminalVisible: !state.terminalVisible };
|
||||
emit();
|
||||
},
|
||||
|
||||
setSidebarView(view: "files" | "search") {
|
||||
state = { ...state, sidebarView: view, sidebarVisible: true };
|
||||
emit();
|
||||
},
|
||||
|
||||
setSearchQuery(query: string) {
|
||||
state = { ...state, searchQuery: query };
|
||||
emit();
|
||||
},
|
||||
|
||||
setSearchResults(results: SearchResult[]) {
|
||||
state = { ...state, searchResults: results };
|
||||
emit();
|
||||
},
|
||||
|
||||
setSearchCaseSensitive(value: boolean) {
|
||||
state = { ...state, searchCaseSensitive: value };
|
||||
emit();
|
||||
},
|
||||
|
||||
setSearchRegex(value: boolean) {
|
||||
state = { ...state, searchRegex: value };
|
||||
emit();
|
||||
},
|
||||
|
||||
setSplitEditor(path: string | null) {
|
||||
state = { ...state, splitEditorPath: path };
|
||||
emit();
|
||||
},
|
||||
|
||||
setFileTreeFilter(filter: string) {
|
||||
state = { ...state, fileTreeFilter: filter };
|
||||
emit();
|
||||
},
|
||||
|
||||
reorderTabs(fromIndex: number, toIndex: number) {
|
||||
const tabs = [...state.tabs];
|
||||
const [moved] = tabs.splice(fromIndex, 1);
|
||||
tabs.splice(toIndex, 0, moved);
|
||||
state = { ...state, tabs };
|
||||
emit();
|
||||
},
|
||||
|
||||
reloadFileContent(path: string, content: string) {
|
||||
state = {
|
||||
...state,
|
||||
tabs: state.tabs.map((t) =>
|
||||
t.path === path ? { ...t, content, savedContent: content } : t
|
||||
),
|
||||
};
|
||||
emit();
|
||||
},
|
||||
|
||||
setCursor(line: number, col: number) {
|
||||
state = { ...state, cursorLine: line, cursorCol: col };
|
||||
emit();
|
||||
},
|
||||
|
||||
updateTabLanguage(path: string, language: string) {
|
||||
state = {
|
||||
...state,
|
||||
tabs: state.tabs.map((t) => (t.path === path ? { ...t, language } : t)),
|
||||
};
|
||||
emit();
|
||||
},
|
||||
|
||||
updateSettings(partial: Partial<EditorSettings>) {
|
||||
const settings = { ...state.settings, ...partial };
|
||||
state = { ...state, settings };
|
||||
localStorage.setItem("editor-settings", JSON.stringify(settings));
|
||||
emit();
|
||||
},
|
||||
|
||||
getState() {
|
||||
return state;
|
||||
},
|
||||
};
|
||||
80
src/lib/themes.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
const darkTheme: Record<string, string> = {
|
||||
"--color-background": "#1e1e1e",
|
||||
"--color-foreground": "#cccccc",
|
||||
"--color-card": "#252526",
|
||||
"--color-card-foreground": "#cccccc",
|
||||
"--color-popover": "#252526",
|
||||
"--color-popover-foreground": "#cccccc",
|
||||
"--color-primary": "#007acc",
|
||||
"--color-primary-foreground": "#ffffff",
|
||||
"--color-secondary": "#3c3c3c",
|
||||
"--color-secondary-foreground": "#cccccc",
|
||||
"--color-muted": "#2d2d2d",
|
||||
"--color-muted-foreground": "#969696",
|
||||
"--color-accent": "#04395e",
|
||||
"--color-accent-foreground": "#ffffff",
|
||||
"--color-destructive": "#f44747",
|
||||
"--color-destructive-foreground": "#ffffff",
|
||||
"--color-border": "#3c3c3c",
|
||||
"--color-input": "#3c3c3c",
|
||||
"--color-ring": "#007acc",
|
||||
"--color-sidebar-background": "#252526",
|
||||
"--color-sidebar-foreground": "#cccccc",
|
||||
"--color-sidebar-accent": "#37373d",
|
||||
"--color-sidebar-accent-foreground": "#cccccc",
|
||||
"--color-sidebar-border": "#3c3c3c",
|
||||
};
|
||||
|
||||
const lightTheme: Record<string, string> = {
|
||||
"--color-background": "#ffffff",
|
||||
"--color-foreground": "#333333",
|
||||
"--color-card": "#f3f3f3",
|
||||
"--color-card-foreground": "#333333",
|
||||
"--color-popover": "#f3f3f3",
|
||||
"--color-popover-foreground": "#333333",
|
||||
"--color-primary": "#007acc",
|
||||
"--color-primary-foreground": "#ffffff",
|
||||
"--color-secondary": "#e0e0e0",
|
||||
"--color-secondary-foreground": "#333333",
|
||||
"--color-muted": "#f0f0f0",
|
||||
"--color-muted-foreground": "#717171",
|
||||
"--color-accent": "#c8ddf1",
|
||||
"--color-accent-foreground": "#333333",
|
||||
"--color-destructive": "#d32f2f",
|
||||
"--color-destructive-foreground": "#ffffff",
|
||||
"--color-border": "#d4d4d4",
|
||||
"--color-input": "#d4d4d4",
|
||||
"--color-ring": "#007acc",
|
||||
"--color-sidebar-background": "#f3f3f3",
|
||||
"--color-sidebar-foreground": "#333333",
|
||||
"--color-sidebar-accent": "#e8e8e8",
|
||||
"--color-sidebar-accent-foreground": "#333333",
|
||||
"--color-sidebar-border": "#d4d4d4",
|
||||
};
|
||||
|
||||
export function applyTheme(theme: "dark" | "light") {
|
||||
const vars = theme === "dark" ? darkTheme : lightTheme;
|
||||
const root = document.documentElement;
|
||||
for (const [key, value] of Object.entries(vars)) {
|
||||
root.style.setProperty(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadCustomCSS(path: string | null) {
|
||||
const existing = document.getElementById("custom-theme-css");
|
||||
if (existing) existing.remove();
|
||||
|
||||
if (!path) return;
|
||||
|
||||
try {
|
||||
const css = await invoke<string>("read_file", { path });
|
||||
const style = document.createElement("style");
|
||||
style.id = "custom-theme-css";
|
||||
style.textContent = css;
|
||||
document.head.appendChild(style);
|
||||
} catch (err) {
|
||||
console.error("Failed to load custom CSS:", err);
|
||||
}
|
||||
}
|
||||
6
src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
10
src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
30
tsconfig.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
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"]
|
||||
}
|
||||
32
vite.config.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import path from "path";
|
||||
|
||||
// @ts-expect-error process is a nodejs global
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
clearScreen: false,
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
}));
|
||||