Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b40358e0f5 | |||
| 29303cbf43 | |||
| c175d734d7 | |||
| 0da836b0ac | |||
| 6c16d4a4f4 | |||
| 8e2cac4437 | |||
| 168b6afab3 | |||
| de6d55ad84 | |||
| 1675a7cd49 | |||
| 0d04c8b2a4 |
4
.gitignore
vendored
@ -22,3 +22,7 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Secrets — never commit keys (OpenRouter key is baked at build time from src-tauri/openrouter.key)
|
||||
*.key
|
||||
latest.json
|
||||
|
||||
10
RELEASING.md
@ -16,11 +16,19 @@ https://gitea.exbytestudios.com/ExbytePublicServices/exbyte-depot-viewer-perforc
|
||||
Без приватного ключа собрать валидное обновление невозможно — сделай бэкап ключа
|
||||
в надёжном месте (менеджер паролей / приватный репозиторий секретов).
|
||||
|
||||
## Ключ OpenRouter (AI-функции)
|
||||
|
||||
AI-ключ **вшивается в бинарник при компиляции** из файла `src-tauri/openrouter.key`
|
||||
(git-ignored, `*.key` в .gitignore). Без этого файла сборка упадёт с ошибкой
|
||||
`include_str!` — перед сборкой убедись, что файл на месте. Чтобы сменить ключ,
|
||||
замени содержимое файла и пересобери.
|
||||
|
||||
## Как выпустить новую версию
|
||||
|
||||
1. Подними версию в **двух** местах (должны совпадать):
|
||||
1. Подними версию в **трёх** местах (должны совпадать):
|
||||
- `package.json` → `"version"`
|
||||
- `src-tauri/tauri.conf.json` → `"version"`
|
||||
- `src-tauri/Cargo.toml` → `version`
|
||||
|
||||
2. Собери подписанный инсталлятор:
|
||||
```powershell
|
||||
|
||||
@ -2,9 +2,8 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tauri + React + Typescript</title>
|
||||
<title>Exbyte Depot</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "exbyte-depot",
|
||||
"private": true,
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
12
src-tauri/Cargo.lock
generated
@ -874,6 +874,15 @@ version = "1.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
@ -951,8 +960,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "exbyte-depot"
|
||||
version = "0.2.0"
|
||||
version = "0.2.4"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
[package]
|
||||
name = "exbyte-depot"
|
||||
version = "0.2.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
version = "0.2.4"
|
||||
description = "Exbyte Depot — native Perforce client by Exbyte Studios"
|
||||
authors = ["Exbyte Studios"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
@ -23,6 +23,8 @@ tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tauri-plugin-dialog = "2.7.1"
|
||||
# decode p4 output on non-UTF8 (ANSI/cp1251) systems — Cyrillic paths
|
||||
encoding_rs = "0.8"
|
||||
|
||||
# Auto-updater (desktop only — checks Gitea Releases for a signed new version)
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
|
||||
21
src-tauri/app-icon.svg
Normal file
@ -0,0 +1,21 @@
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#9a8bf9"/>
|
||||
<stop offset="1" stop-color="#5b49e6"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="glow" cx="0.5" cy="0.36" r="0.72">
|
||||
<stop offset="0" stop-color="#ffffff" stop-opacity="0.30"/>
|
||||
<stop offset="1" stop-color="#ffffff" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<rect x="64" y="64" width="896" height="896" rx="224" fill="url(#bg)"/>
|
||||
<rect x="64" y="64" width="896" height="896" rx="224" fill="url(#glow)"/>
|
||||
<g transform="translate(232,236) scale(23.3)" fill="none" stroke="#ffffff" stroke-width="1.35" stroke-linejoin="round" stroke-linecap="round">
|
||||
<path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z"/>
|
||||
<path d="m3 7 9 5 9-5M12 12v10"/>
|
||||
</g>
|
||||
<g transform="translate(232,236) scale(23.3)">
|
||||
<path d="M12 2 3 7l9 5 9-5-9-5Z" fill="#ffffff" fill-opacity="0.16"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1014 B |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 974 B After Width: | Height: | Size: 1.7 KiB |
BIN
src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 3.0 KiB |
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src-tauri/icons/android/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 7.6 KiB |
BIN
src-tauri/icons/android/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 6.5 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
src-tauri/icons/android/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 6.1 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 9.8 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
src-tauri/icons/android/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 34 KiB |
BIN
src-tauri/icons/android/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
BIN
src-tauri/icons/app-icon-src.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 40 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 970 B |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 5.0 KiB |
BIN
src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
1132
src-tauri/src/lib.rs
@ -1,7 +1,8 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "exbyte-depot",
|
||||
"version": "0.2.0",
|
||||
"productName": "Exbyte Depot",
|
||||
"mainBinaryName": "Exbyte Depot",
|
||||
"version": "0.2.4",
|
||||
"identifier": "com.bonchellon.exbyte-depot",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@ -23,7 +24,8 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
"csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost https://openrouter.ai; worker-src 'self' blob:; object-src 'none'; base-uri 'self'",
|
||||
"devCsp": "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: asset: http://asset.localhost; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost ws://localhost:1420 http://localhost:1420 https://openrouter.ai; worker-src 'self' blob:; object-src 'none'; base-uri 'self'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
264
src/App.css
@ -9,6 +9,7 @@
|
||||
--bar:rgba(255,255,255,.015);--shadow:0 40px 90px -30px rgba(0,0,0,.7);
|
||||
--sunk:rgba(0,0,0,.15);--chip:rgba(0,0,0,.4);--hover:rgba(255,255,255,.05);
|
||||
--diff-add-c:#a8ecca;--diff-del-c:#f3a9b8;
|
||||
--logo-bg:#0c0e1a;--logo-ring:rgba(124,110,246,.28);
|
||||
--font:"Segoe UI Variable Text","Segoe UI",-apple-system,BlinkMacSystemFont,system-ui,"Helvetica Neue",Arial,sans-serif;
|
||||
--mono:"Cascadia Code","JetBrains Mono",ui-monospace,"SF Mono",Consolas,monospace;
|
||||
}
|
||||
@ -23,16 +24,26 @@ body.light{
|
||||
--bar:rgba(0,0,0,.015);--shadow:0 40px 90px -30px rgba(60,60,90,.35);
|
||||
--sunk:transparent;--chip:rgba(255,255,255,.72);--hover:rgba(0,0,0,.045);
|
||||
--diff-add-c:#137a4e;--diff-del-c:#c02b52;
|
||||
--logo-bg:#e9f4ff;--logo-ring:rgba(124,110,246,.22);
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
html,body,#root{height:100%}
|
||||
body{
|
||||
font-family:var(--font);color:var(--txt);background:var(--bg);
|
||||
-webkit-font-smoothing:antialiased;letter-spacing:.1px;overflow:hidden;
|
||||
/* native-app feel: UI chrome text isn't selectable (this is not a web page) */
|
||||
-webkit-user-select:none;user-select:none;cursor:default;
|
||||
}
|
||||
button{font-family:var(--font)}
|
||||
/* …but keep selection where copying/editing text is genuinely useful */
|
||||
input,textarea,[contenteditable="true"],
|
||||
.code,.code-body,.code-edit,.termout,.termbody,.blame-body,.diff-body,.diff,.logbody,.explain-body{
|
||||
-webkit-user-select:text;user-select:text;cursor:auto;
|
||||
}
|
||||
|
||||
.win{height:100vh;width:100%;display:flex;flex-direction:column;
|
||||
/* height divided by the zoom factor: CSS `zoom` shrinks the box but `vh` still
|
||||
measures the unzoomed viewport, so without this a gap grows below at zoom<1 */
|
||||
.win{height:calc(100vh / var(--zoom,1));width:100%;display:flex;flex-direction:column;
|
||||
background:linear-gradient(180deg,var(--win-a),var(--win-b));overflow:hidden}
|
||||
|
||||
/* titlebar + inline menu */
|
||||
@ -46,9 +57,10 @@ button{font-family:var(--font)}
|
||||
.wbtn.close:hover{background:#e81123;color:#fff}
|
||||
.wbtn svg{width:11px;height:11px}
|
||||
.brand{display:flex;align-items:center;gap:10px;padding-right:10px;margin-right:4px;border-right:1px solid var(--border-soft)}
|
||||
.logo{width:22px;height:22px;border-radius:7px;background:linear-gradient(145deg,var(--accent-2),var(--accent-deep));
|
||||
box-shadow:0 0 14px rgba(124,110,246,.55);display:grid;place-items:center;flex:0 0 auto}
|
||||
.logo svg{width:13px;height:13px}
|
||||
/* theme-aware brand tile: dark tile in dark theme, light tile in light theme */
|
||||
.logo{width:24px;height:24px;border-radius:8px;background:var(--logo-bg);
|
||||
box-shadow:inset 0 0 0 1px var(--logo-ring),0 2px 8px rgba(0,0,0,.28);display:grid;place-items:center;flex:0 0 auto}
|
||||
.logo .exlogo{width:18px;height:18px}
|
||||
.app-name{font-size:13px;font-weight:600;white-space:nowrap}.app-name span{color:var(--faint);font-weight:500}
|
||||
.conn-pill{align-self:center;margin-left:auto;display:flex;align-items:center;gap:8px;font-size:11.5px;color:var(--muted);
|
||||
background:rgba(62,207,142,.08);border:1px solid rgba(62,207,142,.2);padding:4px 10px;border-radius:20px;white-space:nowrap}
|
||||
@ -66,6 +78,9 @@ button{font-family:var(--font)}
|
||||
.menu.open .dropdown{display:flex}
|
||||
.di{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;font-size:12.5px;color:var(--txt);cursor:pointer}
|
||||
.di:hover{background:rgba(124,110,246,.14)}
|
||||
.di-ic{flex:0 0 auto;display:grid;place-items:center;width:16px;height:16px;color:var(--muted)}
|
||||
.di-ic svg{width:15px;height:15px}
|
||||
.di:hover .di-ic{color:var(--accent-2)}
|
||||
.di .kb{margin-left:auto;font-size:10.5px;color:var(--faint);font-family:var(--mono)}
|
||||
.di.ext{color:var(--accent-2)}
|
||||
.sep{height:1px;background:var(--border-soft);margin:4px 6px}
|
||||
@ -122,12 +137,49 @@ body.resizing{cursor:col-resize!important;user-select:none}
|
||||
.pushbar b{color:var(--add)}
|
||||
.pushdot{width:8px;height:8px;border-radius:50%;background:var(--add);box-shadow:0 0 8px var(--add);flex:0 0 auto}
|
||||
.pusharr{margin-left:auto;font-weight:700;font-size:14px}
|
||||
|
||||
/* pending changelists (committed, not yet submitted) */
|
||||
.pending{margin:8px 8px 2px;display:flex;flex-direction:column;gap:6px;max-height:42%;overflow-y:auto;flex:0 0 auto}
|
||||
.pending-h{display:flex;align-items:center;gap:8px;padding:1px 4px 3px}
|
||||
.pending-t{display:flex;align-items:center;gap:8px;font-size:10.5px;text-transform:uppercase;letter-spacing:.6px;color:var(--add);font-weight:700}
|
||||
.pushall{margin-left:auto;font-size:11.5px;font-weight:600;color:var(--add);background:rgba(62,207,142,.1);border:1px solid rgba(62,207,142,.32);border-radius:8px;padding:4px 11px;cursor:pointer}
|
||||
.pushall:hover{filter:brightness(1.08)}
|
||||
.clcard{border:1px solid rgba(62,207,142,.28);background:rgba(62,207,142,.06);border-radius:11px;overflow:hidden;flex:0 0 auto}
|
||||
.clcard-h{display:flex;align-items:center;gap:9px;padding:9px 11px;cursor:pointer}
|
||||
.clcard-h:hover{background:rgba(62,207,142,.06)}
|
||||
.cl-chev{width:15px;height:15px;flex:0 0 auto;color:var(--faint);display:grid;place-items:center;transition:transform .18s;transform:rotate(-90deg)}
|
||||
.cl-chev.open{transform:rotate(0deg)}
|
||||
.cl-chev svg{width:15px;height:15px}
|
||||
.cl-num{font-family:var(--mono);font-size:12px;color:var(--accent-2);flex:0 0 auto;font-variant-numeric:tabular-nums}
|
||||
.cl-desc{flex:1;min-width:0;font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.cl-count{flex:0 0 auto;font-size:11px;color:var(--faint)}
|
||||
.cl-actions{flex:0 0 auto;display:flex;align-items:center;gap:5px}
|
||||
.cl-submit{width:27px;height:27px;border:none;border-radius:8px;cursor:pointer;color:#fff;font-size:14px;font-weight:700;
|
||||
background:linear-gradient(135deg,var(--add),#1f9f6e);box-shadow:0 4px 12px -5px rgba(62,207,142,.7)}
|
||||
.cl-submit:hover{filter:brightness(1.1)}
|
||||
.cl-more{width:27px;height:27px;border:1px solid var(--border);background:var(--panel);color:var(--muted);border-radius:8px;cursor:pointer;font-size:15px;line-height:1;display:grid;place-items:center}
|
||||
.cl-more:hover{color:var(--txt);border-color:var(--accent)}
|
||||
.cl-files{border-top:1px solid rgba(62,207,142,.2);padding:4px}
|
||||
.cl-empty{padding:8px 10px;font-size:11.5px;color:var(--faint)}
|
||||
.cl-file{display:flex;align-items:center;gap:10px;padding:6px 8px;border-radius:8px;cursor:pointer}
|
||||
.cl-file:hover{background:var(--hover)}
|
||||
.cl-file.sel{background:rgba(124,110,246,.14);box-shadow:inset 0 0 0 1px rgba(124,110,246,.25)}
|
||||
.cl-fname{display:flex;flex-direction:column;gap:1px;min-width:0;flex:1}
|
||||
.cl-fname .n{font-size:12.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.cl-fname .p{font-size:10.5px;color:var(--faint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
|
||||
/* text prompt (edit description) */
|
||||
.modal-card.prompt{width:460px;max-width:calc(100vw - 40px);text-align:left;align-items:stretch}
|
||||
.modal-card.prompt h3{text-align:left}
|
||||
.prompt-label{font-size:11.5px;color:var(--faint);margin:2px 0 9px}
|
||||
.prompt-input{width:100%;min-height:96px;resize:vertical;background:var(--input-bg);border:1px solid var(--border);border-radius:10px;padding:10px 12px;font-size:13px;color:var(--txt);font-family:var(--font);outline:none;line-height:1.5}
|
||||
.prompt-input:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(124,110,246,.15)}
|
||||
.chhead{display:flex;align-items:center;gap:10px;padding:9px 16px;font-size:12px;color:var(--muted);border-bottom:1px solid var(--border-soft)}
|
||||
.chk{width:16px;height:16px;border-radius:6px;border:1.5px solid var(--accent);
|
||||
background:linear-gradient(145deg,var(--accent-2),var(--accent-deep));display:grid;place-items:center;flex:0 0 auto;box-shadow:0 0 8px rgba(124,110,246,.4)}
|
||||
.chk svg{width:10px;height:10px;color:#fff}
|
||||
.chk{cursor:pointer}
|
||||
.chk.empty{background:transparent;border-color:var(--border);box-shadow:none}
|
||||
.chk.off{background:transparent;border-color:var(--border);box-shadow:none}
|
||||
.crumb{display:flex;align-items:center;gap:4px;font-family:var(--mono);font-size:12px;flex-wrap:wrap}
|
||||
.crumb .seg{color:var(--accent-2);cursor:pointer}.crumb .seg:hover{text-decoration:underline}
|
||||
.crumb .sep{color:var(--faint)}
|
||||
@ -324,6 +376,37 @@ body.resizing{cursor:col-resize!important;user-select:none}
|
||||
.logcount{margin-left:auto;flex:0 0 auto;color:var(--faint);white-space:nowrap}
|
||||
.logrow.err .logcount{color:var(--del)}
|
||||
|
||||
/* bottom dock — tabbed Log / Terminal / Unreal */
|
||||
.dockpanel{position:relative;flex:0 0 240px;display:flex;flex-direction:column;min-height:0;border-top:1px solid var(--border);background:var(--sunk)}
|
||||
.dockhandle{position:absolute;top:-3px;left:0;right:0;height:7px;cursor:row-resize;z-index:9}
|
||||
body.resizing-v{cursor:row-resize!important;user-select:none}
|
||||
.dockhead{flex:0 0 auto;display:flex;align-items:center;gap:6px;padding:4px 10px 0;border-bottom:1px solid var(--border-soft)}
|
||||
.docktabs{display:flex;gap:2px}
|
||||
.docktab{display:flex;align-items:center;gap:6px;border:none;background:none;color:var(--muted);font-family:var(--font);font-size:12px;cursor:pointer;padding:7px 12px;border-radius:8px 8px 0 0;border-bottom:2px solid transparent;transition:.12s}
|
||||
.docktab svg{width:14px;height:14px}
|
||||
.docktab:hover{color:var(--txt);background:var(--hover)}
|
||||
.docktab.on{color:var(--txt);border-bottom-color:var(--accent)}
|
||||
.docktab.on svg{color:var(--accent-2)}
|
||||
.dockspace{flex:1}
|
||||
.dockpanel .loghbtn{margin-left:0}
|
||||
/* terminal */
|
||||
.term{flex:1;display:flex;flex-direction:column;min-height:0}
|
||||
.termbody{flex:1;overflow-y:auto;min-height:0;padding:8px 12px;font-family:var(--mono);font-size:12px;line-height:1.5}
|
||||
.termhint{color:var(--faint);font-family:var(--font);font-size:12px;padding:8px 2px;line-height:1.55}
|
||||
.termhint.err{color:var(--del)}
|
||||
.termblock{margin-bottom:9px}
|
||||
.termcmd{color:var(--txt);white-space:pre-wrap;word-break:break-all}
|
||||
.termprompt{color:var(--accent);font-weight:700;user-select:none;margin-right:3px}
|
||||
.termout{margin:2px 0 0;white-space:pre-wrap;word-break:break-word;color:var(--muted)}
|
||||
.termout.err{color:var(--del)}
|
||||
.termout.run{color:var(--faint);display:flex;align-items:center;gap:8px}
|
||||
.terminput-wrap{position:relative;flex:0 0 auto;display:flex;align-items:center;gap:6px;padding:9px 12px;border-top:1px solid var(--border-soft);background:var(--panel)}
|
||||
.terminput{flex:1;background:none;border:none;outline:none;color:var(--txt);font-family:var(--mono);font-size:12.5px}
|
||||
.termsug{position:absolute;left:32px;bottom:calc(100% - 1px);min-width:210px;max-height:220px;overflow-y:auto;background:var(--elevated);border:1px solid var(--border);border-radius:10px;box-shadow:0 -14px 34px -14px rgba(0,0,0,.65);z-index:20;padding:4px}
|
||||
.termsug-i{padding:6px 10px;font-family:var(--mono);font-size:12px;color:var(--muted);border-radius:7px;cursor:pointer;white-space:nowrap}
|
||||
.termsug-i b{color:var(--accent-2)}
|
||||
.termsug-i.on,.termsug-i:hover{background:rgba(124,110,246,.14);color:var(--txt)}
|
||||
|
||||
/* animated startup splash */
|
||||
.splash{display:flex;flex-direction:column;align-items:center;gap:16px;padding:20px;animation:updin .5s ease}
|
||||
.splash-logo{width:64px;height:64px;border-radius:18px;display:grid;place-items:center;color:#fff;
|
||||
@ -426,6 +509,14 @@ body.resizing{cursor:col-resize!important;user-select:none}
|
||||
.gbtn:hover{color:var(--txt);border-color:var(--accent)}.gbtn svg{width:14px;height:14px}
|
||||
.gbtn.vsc svg{color:#3aa0ff}
|
||||
.gbtn.vsc:hover{border-color:#3aa0ff;color:#3aa0ff}
|
||||
.gbtn.icon{padding:7px;width:34px;height:32px;justify-content:center;gap:0}
|
||||
.gbtn.icon svg{width:16px;height:16px}
|
||||
/* code-editor picker in Settings */
|
||||
.edsel{flex:none;min-width:170px;font-size:12.5px;font-family:var(--font);color:var(--txt);
|
||||
background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:7px 10px;cursor:pointer;transition:.15s}
|
||||
.edsel:hover{border-color:var(--accent)}
|
||||
.edsel:focus{outline:none;border-color:var(--accent)}
|
||||
.edsel option{background:var(--panel);color:var(--txt)}
|
||||
|
||||
.asset{flex:1;min-height:0;display:grid;grid-template-rows:1fr auto}
|
||||
.stage{position:relative;min-height:0;overflow:hidden;display:grid;place-items:center;
|
||||
@ -498,6 +589,21 @@ body.resizing{cursor:col-resize!important;user-select:none}
|
||||
.code-body code{white-space:pre;font-family:inherit;background:none;padding:0}
|
||||
.code.added .code-body{background:rgba(62,207,142,.04)}
|
||||
|
||||
/* built-in code editor */
|
||||
.cb-badge.editing{color:var(--edit);background:rgba(232,176,75,.16)}
|
||||
.ce-actions{margin-left:auto;display:flex;gap:8px;align-items:center}
|
||||
.ce-btn{display:flex;align-items:center;gap:6px;font-size:11.5px;font-weight:600;font-family:var(--font);cursor:pointer;
|
||||
color:var(--muted);background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:5px 11px;transition:.15s}
|
||||
.ce-btn:hover:not(:disabled){color:var(--txt);border-color:var(--accent)}
|
||||
.ce-btn:disabled{opacity:.5;cursor:default}
|
||||
.ce-btn.save{color:#fff;border:none;background:linear-gradient(135deg,var(--accent-2),var(--accent-deep))}
|
||||
.ce-btn.save:hover:not(:disabled){filter:brightness(1.1)}
|
||||
.ce-btn svg{width:13px;height:13px}
|
||||
.code-edit{flex:1;min-height:0;width:100%;resize:none;border:none;outline:none;
|
||||
font-family:var(--mono);font-size:12.5px;line-height:1.7;tab-size:4;color:var(--txt);
|
||||
background:var(--stage-bg);padding:12px 16px;white-space:pre;overflow:auto}
|
||||
.code-edit:focus{box-shadow:inset 3px 0 0 var(--accent)}
|
||||
|
||||
/* highlight.js tokens mapped to the app palette (theme-aware) */
|
||||
.hljs{color:var(--txt);background:none}
|
||||
.hljs-comment,.hljs-quote{color:var(--faint);font-style:italic}
|
||||
@ -519,6 +625,23 @@ body.resizing{cursor:col-resize!important;user-select:none}
|
||||
/* virtualized changelist-file row (History detail) */
|
||||
.crow{position:absolute;left:0;right:0;height:52px;display:flex;gap:12px;align-items:center;padding:0 16px;border-bottom:1px solid var(--border-soft)}
|
||||
.crow:hover{background:var(--hover)}
|
||||
.crow.click{cursor:pointer}
|
||||
.crow.click:hover .crow-go{opacity:1;transform:translateX(0)}
|
||||
.crow.sel{background:rgba(124,110,246,.12);box-shadow:inset 2px 0 0 var(--accent)}
|
||||
.crow-go{margin-left:auto;flex:0 0 auto;color:var(--faint);font-size:18px;line-height:1;opacity:0;transform:translateX(-4px);transition:opacity .12s,transform .12s;display:flex;align-items:center}
|
||||
.crow-go svg{width:16px;height:16px}
|
||||
.gbtn.back{padding:6px 8px}.gbtn.back svg{width:16px;height:16px}
|
||||
/* History = GitHub-style split: changed-files list beside the file content (no covering) */
|
||||
.histsplit{display:flex;min-width:0;min-height:0;overflow:hidden}
|
||||
.histsplit>.right{flex:1 1 auto;min-width:0}
|
||||
/* width comes from a direct inline style on the element (set in ChangeDetail) */
|
||||
.histsplit>.histlist{min-width:0;border-right:1px solid var(--border)}
|
||||
.histsplit.solo>.histlist{flex:1 1 auto;border-right:none} /* nothing open → list fills the panel */
|
||||
/* plain in-flow 8px column between list and preview — only the cursor hints it's draggable.
|
||||
position MUST override the base .vhandle{position:absolute}, or it falls out of the flex flow */
|
||||
.histsplit>.histh{position:relative;flex:none;width:8px;align-self:stretch;cursor:col-resize;background:transparent;top:auto;bottom:auto}
|
||||
.histsplit .histlist .crow-go{opacity:0}
|
||||
.histsplit .histlist .crow.sel .crow-go{opacity:1;transform:translateX(0)}
|
||||
.hnum{font-family:var(--mono);font-size:12px;color:var(--accent-2);font-variant-numeric:tabular-nums;flex:0 0 auto;padding-top:1px}
|
||||
.hbody{display:flex;flex-direction:column;gap:3px;min-width:0}
|
||||
.hdesc{font-size:13px;color:var(--txt);overflow:hidden;text-overflow:ellipsis;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical}
|
||||
@ -601,3 +724,134 @@ body.resizing{cursor:col-resize!important;user-select:none}
|
||||
animation:toastin .25s ease;white-space:pre-wrap;text-align:center}
|
||||
.toast.err{border-color:rgba(242,99,126,.4);color:var(--del)}
|
||||
@keyframes toastin{from{opacity:0;transform:translate(-50%,10px)}to{opacity:1;transform:translate(-50%,0)}}
|
||||
|
||||
/* ---------------- avatars ---------------- */
|
||||
.avatar{position:relative;flex:0 0 auto;border-radius:50%;display:grid;place-items:center;color:#fff;font-weight:700;letter-spacing:.02em;text-transform:uppercase;box-shadow:inset 0 0 0 1px rgba(255,255,255,.14);user-select:none}
|
||||
.adot{position:absolute;right:-1px;bottom:-1px;width:9px;height:9px;border-radius:50%;border:2px solid var(--elevated);background:var(--faint)}
|
||||
.adot.active{background:#3ecb8f}.adot.recent{background:#f4c04e}.adot.idle{background:#6b6b7a}
|
||||
|
||||
/* ---------------- AI commit summary ---------------- */
|
||||
.summrow{display:flex;gap:8px;align-items:stretch}
|
||||
.aibtn{flex:0 0 auto;display:flex;align-items:center;gap:6px;border:1px solid var(--border);border-radius:9px;padding:0 12px;
|
||||
font-size:12px;font-weight:600;font-family:var(--font);cursor:pointer;color:var(--accent-2);
|
||||
background:linear-gradient(135deg,rgba(124,110,246,.16),rgba(124,110,246,.06));transition:.15s}
|
||||
.aibtn:hover:not(:disabled){filter:brightness(1.12);border-color:rgba(124,110,246,.5)}
|
||||
.aibtn:disabled{opacity:.5;cursor:default}
|
||||
.aibtn svg{width:15px;height:15px}
|
||||
.aibtn.busy{color:var(--faint)}
|
||||
.aibtn.icon{padding:0;width:34px;justify-content:center}
|
||||
/* code "Summary" button + AI explanation card in the preview header */
|
||||
.gbtn.ai{color:var(--accent-2);border-color:rgba(124,110,246,.35);background:linear-gradient(135deg,rgba(124,110,246,.14),rgba(124,110,246,.05))}
|
||||
.gbtn.ai:hover{filter:brightness(1.1);border-color:rgba(124,110,246,.55)}
|
||||
.gbtn.ai.busy{color:var(--faint)}
|
||||
.explain{flex:0 0 auto;display:flex;align-items:flex-start;gap:10px;margin:0;padding:12px 16px;
|
||||
background:linear-gradient(135deg,rgba(124,110,246,.1),rgba(124,110,246,.03));border-bottom:1px solid var(--border-soft)}
|
||||
.explain-ic{flex:0 0 auto;color:var(--accent-2);display:flex}
|
||||
.explain-ic svg{width:17px;height:17px}
|
||||
.explain-body{flex:1;font-size:12.5px;line-height:1.55;color:var(--txt);min-width:0}
|
||||
.explain-load{display:flex;align-items:center;gap:8px;color:var(--faint)}
|
||||
.explain-x{flex:0 0 auto;border:none;background:none;color:var(--faint);font-size:18px;line-height:1;cursor:pointer;padding:0 2px}
|
||||
.explain-x:hover{color:var(--txt)}
|
||||
|
||||
/* "Load 3D model" button in the .uasset preview */
|
||||
.load3d{position:absolute;bottom:16px;left:50%;transform:translateX(-50%);display:flex;align-items:center;gap:8px;
|
||||
font-size:12.5px;font-weight:600;font-family:var(--font);color:#fff;cursor:pointer;padding:9px 16px;border-radius:10px;border:none;
|
||||
background:linear-gradient(135deg,var(--accent-2),var(--accent-deep));box-shadow:0 8px 24px -8px rgba(124,110,246,.7);transition:.15s}
|
||||
.load3d:hover:not(:disabled){filter:brightness(1.1)}
|
||||
.load3d:disabled{opacity:.5;cursor:default;box-shadow:none}
|
||||
.load3d svg{width:15px;height:15px}
|
||||
|
||||
/* ---------------- collaboration UI (locks / banners / search / blame / diff / resolve) ---------------- */
|
||||
/* connection + resolve banners */
|
||||
.netbar{display:flex;align-items:center;gap:10px;padding:8px 16px;font-size:12.5px;font-weight:600;flex:0 0 auto}
|
||||
.netbar svg{width:16px;height:16px;flex:0 0 auto}
|
||||
.netbar.off{background:rgba(242,99,126,.14);color:var(--del);border-bottom:1px solid rgba(242,99,126,.3)}
|
||||
.netbar.warn{background:rgba(232,176,75,.14);color:var(--edit);border-bottom:1px solid rgba(232,176,75,.3);cursor:pointer}
|
||||
.netbar.warn:hover{background:rgba(232,176,75,.2)}
|
||||
.netbar-btn{margin-left:auto;border:1px solid currentColor;background:none;color:inherit;font-size:11.5px;font-weight:700;
|
||||
border-radius:7px;padding:4px 12px;cursor:pointer;font-family:var(--font)}
|
||||
/* "held by someone else" pill on a Changes row */
|
||||
.held{display:flex;align-items:center;gap:4px;flex:0 0 auto;font-size:10.5px;font-weight:600;color:var(--muted);
|
||||
background:var(--chip);border:1px solid var(--border-soft);border-radius:7px;padding:2px 7px;max-width:120px;overflow:hidden}
|
||||
.held svg{width:12px;height:12px;flex:0 0 auto}
|
||||
.held span{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.held.locked{color:var(--del);border-color:rgba(242,99,126,.35);background:rgba(242,99,126,.1)}
|
||||
/* search results / resolve list share the srch layout */
|
||||
.srch-list{flex:1;overflow-y:auto;padding:6px 10px 12px}
|
||||
.srch-row{display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:9px;transition:background .12s}
|
||||
.srch-row:hover{background:var(--hover)}
|
||||
.srch-body{flex:1;display:flex;flex-direction:column;gap:1px;min-width:0}
|
||||
.srch-body .n{font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.srch-body .p{font-size:11px;color:var(--faint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.srch-act{flex:0 0 auto;width:30px;height:28px;display:grid;place-items:center;border:1px solid var(--border);
|
||||
background:var(--panel);border-radius:8px;cursor:pointer;color:var(--muted);font-size:13px}
|
||||
.srch-act:hover{color:var(--txt);border-color:var(--accent)}.srch-act svg{width:15px;height:15px}
|
||||
.rslv-bar{display:flex;gap:8px;padding:10px 16px;border-bottom:1px solid var(--border-soft);flex-wrap:wrap}
|
||||
.rslv-act{flex:0 0 auto;border:1px solid var(--border);background:var(--panel);border-radius:7px;padding:5px 11px;
|
||||
font-size:11.5px;font-weight:600;color:var(--muted);cursor:pointer;font-family:var(--font)}
|
||||
.rslv-act:hover{color:var(--txt);border-color:var(--accent)}
|
||||
.rslv-act.on{color:#fff;border-color:transparent;background:linear-gradient(135deg,var(--accent-2),var(--accent-deep))}
|
||||
.rslv-act svg{width:14px;height:14px;display:block}
|
||||
.lk-who{flex:0 0 auto;display:flex;flex-direction:column;align-items:flex-end;gap:1px;font-size:11.5px;color:var(--muted);max-width:170px}
|
||||
.lk-client{font-size:10px;color:var(--faint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:170px}
|
||||
/* blame + diff panes */
|
||||
.picker.blame{width:900px;height:640px}
|
||||
.blame-body,.diff-body{flex:1;overflow:auto;padding:8px 0;font-family:var(--mono);font-size:12px;line-height:1.5;background:var(--stage-bg)}
|
||||
.blame-row{display:flex;gap:0;padding:0 12px;white-space:pre}
|
||||
.blame-row:hover{background:var(--hover)}
|
||||
.bl-cl{flex:0 0 64px;color:var(--accent-2);font-variant-numeric:tabular-nums}
|
||||
.bl-user{flex:0 0 110px;color:var(--edit);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.bl-code{flex:1;color:var(--txt);white-space:pre}
|
||||
.diff-line{padding:0 14px;white-space:pre;color:var(--muted)}
|
||||
.diff-line.add{background:rgba(62,207,142,.12);color:var(--diff-add-c)}
|
||||
.diff-line.del{background:rgba(242,99,126,.12);color:var(--diff-del-c)}
|
||||
.diff-line.hunk{background:rgba(124,110,246,.1);color:var(--accent-2)}
|
||||
|
||||
/* AI settings block inside Settings modal */
|
||||
.ai-set{margin-top:16px;padding-top:14px;border-top:1px solid var(--border-soft)}
|
||||
.ai-set-h{display:flex;align-items:center;gap:8px;font-size:12.5px;font-weight:700;color:var(--txt);margin-bottom:6px}
|
||||
.ai-set-h svg{width:16px;height:16px;color:var(--accent-2)}
|
||||
.keyfield{display:flex;align-items:center;gap:6px;flex:1;max-width:230px}
|
||||
.kf-in{flex:1;min-width:0;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:6px 9px;font-size:12px;color:var(--txt);outline:none;font-family:var(--font)}
|
||||
.kf-in.mono{font-family:var(--mono);font-size:11.5px}
|
||||
.kf-in:focus{border-color:var(--accent)}
|
||||
.kf-eye{flex:0 0 auto;width:28px;height:28px;border:1px solid var(--border);background:var(--panel);border-radius:8px;cursor:pointer;font-size:13px;line-height:1}
|
||||
.ai-hint{font-size:11px;color:var(--faint);line-height:1.5;margin-top:8px}
|
||||
|
||||
/* ---------------- People & Roles ---------------- */
|
||||
.picker.wide{width:720px;max-width:94%;height:560px;max-height:82vh;display:flex;flex-direction:column}
|
||||
.ph-sub{margin-left:8px;font-size:11.5px;color:var(--faint);font-variant-numeric:tabular-nums}
|
||||
.picker.wide .ph-sub{margin-right:auto}
|
||||
.ur-body{flex:1;display:flex;min-height:0}
|
||||
.ur-list{flex:0 0 300px;display:flex;flex-direction:column;border-right:1px solid var(--border-soft);min-height:0}
|
||||
.ur-filter{display:flex;align-items:center;gap:8px;padding:10px 12px;border-bottom:1px solid var(--border-soft)}
|
||||
.ur-filter svg{width:15px;height:15px;color:var(--faint);flex:0 0 auto}
|
||||
.ur-filter input{flex:1;background:none;border:none;outline:none;color:var(--txt);font-size:12.5px;font-family:var(--font)}
|
||||
.ur-scroll{flex:1;overflow-y:auto;padding:6px}
|
||||
.ur-row{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:10px;cursor:pointer;transition:background .12s}
|
||||
.ur-row:hover{background:var(--hover)}
|
||||
.ur-row.on{background:rgba(124,110,246,.13);box-shadow:inset 0 0 0 1px rgba(124,110,246,.28)}
|
||||
.ur-meta{display:flex;flex-direction:column;gap:1px;min-width:0;flex:1}
|
||||
.ur-name{font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:flex;align-items:center;gap:6px}
|
||||
.ur-you{font-size:9.5px;font-weight:700;text-transform:uppercase;letter-spacing:.04em;color:var(--accent-2);background:rgba(124,110,246,.16);border-radius:6px;padding:1px 6px}
|
||||
.ur-sub{font-size:11px;color:var(--faint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.ur-actd{width:8px;height:8px;border-radius:50%;flex:0 0 auto;background:var(--faint)}
|
||||
.ur-actd.active{background:#3ecb8f;box-shadow:0 0 0 3px rgba(62,203,143,.18)}
|
||||
.ur-actd.recent{background:#f4c04e}.ur-actd.idle{background:#4b4b58}
|
||||
.ur-empty{padding:16px;font-size:12px;color:var(--faint);text-align:center}
|
||||
.ur-detail{flex:1;display:flex;flex-direction:column;min-width:0;overflow-y:auto}
|
||||
.ur-dhead{display:flex;align-items:center;gap:14px;padding:18px 20px 14px;border-bottom:1px solid var(--border-soft)}
|
||||
.ur-dinfo{min-width:0}
|
||||
.ur-dname{font-size:16px;font-weight:700}
|
||||
.ur-dsub{font-size:12px;color:var(--faint);margin-top:2px}
|
||||
.ur-grouphd{display:flex;align-items:center;gap:8px;padding:14px 20px 8px;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--faint)}
|
||||
.ur-groups{display:flex;flex-wrap:wrap;gap:8px;padding:0 20px}
|
||||
.ur-chip{display:flex;align-items:center;gap:7px;border:1px solid var(--border);background:var(--panel);color:var(--muted);
|
||||
border-radius:9px;padding:7px 11px;font-size:12.5px;font-weight:500;font-family:var(--font);cursor:pointer;transition:.14s}
|
||||
.ur-chip:hover:not(:disabled){border-color:var(--accent);color:var(--txt)}
|
||||
.ur-chip:disabled{opacity:.6;cursor:default}
|
||||
.ur-chip.on{border-color:rgba(124,110,246,.5);background:rgba(124,110,246,.13);color:var(--txt)}
|
||||
.urc-box{width:15px;height:15px;border-radius:5px;border:1.5px solid var(--border);display:grid;place-items:center;flex:0 0 auto;background:var(--panel)}
|
||||
.ur-chip.on .urc-box{background:var(--accent);border-color:var(--accent);color:#fff}
|
||||
.urc-box svg{width:11px;height:11px}
|
||||
.ur-detail .ai-hint{padding:12px 20px 20px}
|
||||
|
||||
1283
src/App.tsx
@ -45,22 +45,30 @@ function esc(s: string): string {
|
||||
const MAX = 800_000; // don't try to render/highlight enormous files
|
||||
|
||||
// Preview source files like GitHub: syntax-highlighted content with line numbers
|
||||
// for new files, or a colored unified diff for edits.
|
||||
export default function CodeView({ file }: { file: OpenedFile }) {
|
||||
// for new files, or a colored unified diff for edits. Working-copy code files can
|
||||
// be edited in place (built-in editor) and saved before committing.
|
||||
export default function CodeView({ file, onFlash }: { file: OpenedFile; onFlash?: (text: string, err?: boolean) => void }) {
|
||||
const dp = file.depotFile || "";
|
||||
const spec = file._spec || ""; // set → read this exact submitted revision
|
||||
const { name } = splitPath(dp);
|
||||
const action = (file.action || "").toLowerCase();
|
||||
const [diff, setDiff] = useState<string | null>(null);
|
||||
const [code, setCode] = useState<string | null>(null);
|
||||
const [err, setErr] = useState("");
|
||||
const [tooBig, setTooBig] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [reload, setReload] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
setDiff(null); setCode(null); setErr(""); setTooBig(false);
|
||||
// edits show a diff; adds have none, so fetch it but don't depend on it
|
||||
p4.diff(dp).then((d) => live && setDiff(d)).catch(() => live && setDiff(""));
|
||||
p4.readDepot(dp).then((buf) => {
|
||||
setDiff(null); setCode(null); setErr(""); setTooBig(false); setEditing(false);
|
||||
// working-copy edits show a diff against the depot; historical revisions just
|
||||
// show their full content (no meaningful working diff to fetch).
|
||||
if (!spec) p4.diff(dp).then((d) => live && setDiff(d)).catch(() => live && setDiff(""));
|
||||
const load = spec ? p4.printDepot(spec) : p4.readDepot(dp);
|
||||
load.then((buf) => {
|
||||
if (!live) return;
|
||||
if (buf.byteLength > MAX) { setTooBig(true); setCode(""); return; }
|
||||
const bytes = new Uint8Array(buf);
|
||||
@ -69,8 +77,27 @@ export default function CodeView({ file }: { file: OpenedFile }) {
|
||||
setCode(new TextDecoder("utf-8", { fatal: false }).decode(bytes));
|
||||
}).catch((e) => live && setErr(String(e)));
|
||||
return () => { live = false; };
|
||||
}, [dp]);
|
||||
}, [dp, spec, reload]);
|
||||
|
||||
// built-in editor: editable for a code file in the working copy (not history/binary/huge)
|
||||
const canEdit = !spec && !tooBig && err !== "binary" && code != null;
|
||||
function startEdit() { setDraft(code || ""); setEditing(true); }
|
||||
function cancelEdit() { setEditing(false); }
|
||||
async function save() {
|
||||
setSaving(true);
|
||||
try {
|
||||
await p4.writeDepot(dp, draft);
|
||||
setCode(draft);
|
||||
setEditing(false);
|
||||
onFlash?.(t("Saved {name}", { name }));
|
||||
setReload((r) => r + 1); // refresh diff/status after the on-disk change
|
||||
} catch (e) { onFlash?.(String(e), true); }
|
||||
finally { setSaving(false); }
|
||||
}
|
||||
const pencil = <svg viewBox="0 0 24 24" width="13" height="13" fill="none"><path d="m14.5 5.5 4 4M4 20l1-4L16.5 4.5a2 2 0 0 1 3 3L8 19l-4 1Z" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" /></svg>;
|
||||
|
||||
// NB: all hooks must run before any early return (Rules of Hooks) — compute
|
||||
// the highlighted body here, above the editing/diff/error returns below.
|
||||
const lang = langOf(name);
|
||||
const highlighted = useMemo(() => {
|
||||
if (code == null) return "";
|
||||
@ -78,6 +105,35 @@ export default function CodeView({ file }: { file: OpenedFile }) {
|
||||
catch { return esc(code); }
|
||||
}, [code, lang]);
|
||||
|
||||
// editing mode — plain textarea over the working copy
|
||||
if (editing) {
|
||||
return (
|
||||
<div className="right-body">
|
||||
<div className="codebar">
|
||||
<span className="cb-badge editing">{t("Editing")}</span>
|
||||
<span className="cb-name">{name}</span>
|
||||
<span className="ce-actions">
|
||||
<button className="ce-btn" onClick={cancelEdit} disabled={saving}>{t("Cancel")}</button>
|
||||
<button className="ce-btn save" onClick={save} disabled={saving || draft === code}>{saving ? <span className="ldr sm" /> : null}{t("Save")}</button>
|
||||
</span>
|
||||
</div>
|
||||
<textarea className="code-edit" value={draft} spellCheck={false} onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") cancelEdit();
|
||||
if (e.key === "s" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); if (draft !== code) save(); }
|
||||
// Tab inserts a tab instead of leaving the field
|
||||
if (e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
const ta = e.currentTarget; const s = ta.selectionStart, en = ta.selectionEnd;
|
||||
const nv = draft.slice(0, s) + "\t" + draft.slice(en);
|
||||
setDraft(nv);
|
||||
requestAnimationFrame(() => { ta.selectionStart = ta.selectionEnd = s + 1; });
|
||||
}
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (err === "binary") return <div className="nofile">{t("Binary file — no code preview.")}</div>;
|
||||
if (err) return <div className="nofile" style={{ color: "var(--del)" }}>{err}</div>;
|
||||
if (tooBig) return <div className="nofile">{t("File is too large to preview.")}</div>;
|
||||
@ -87,7 +143,9 @@ export default function CodeView({ file }: { file: OpenedFile }) {
|
||||
const lines = diff.split("\n");
|
||||
return (
|
||||
<div className="right-body">
|
||||
<div className="codebar"><span className="cb-badge diff">{t("Diff")}</span><span className="cb-name">{name}</span></div>
|
||||
<div className="codebar"><span className="cb-badge diff">{t("Diff")}</span><span className="cb-name">{name}</span>
|
||||
{canEdit && <button className="ce-btn edit" style={{ marginLeft: "auto" }} onClick={startEdit}>{pencil}{t("Edit")}</button>}
|
||||
</div>
|
||||
<div className="diff">
|
||||
{lines.map((ln, i) => {
|
||||
let cls = "";
|
||||
@ -110,6 +168,7 @@ export default function CodeView({ file }: { file: OpenedFile }) {
|
||||
<span className={"cb-badge" + (action.includes("add") ? " add" : "")}>{action.includes("add") ? t("New file") : lang ? (LANG_LABEL[lang] || "") : t("Text")}</span>
|
||||
<span className="cb-name">{name}</span>
|
||||
<span className="cb-lines">{t("{n} lines", { n: total })}</span>
|
||||
{canEdit && <button className="ce-btn edit" onClick={startEdit}>{pencil}{t("Edit")}</button>}
|
||||
</div>
|
||||
<div className={"code" + (action.includes("add") ? " added" : "")}>
|
||||
<div className="code-gutter">{Array.from({ length: total }, (_, i) => <span key={i}>{i + 1}</span>)}</div>
|
||||
|
||||
@ -14,7 +14,7 @@ import { t } from "./i18n";
|
||||
|
||||
type Api = { toggleWire: (b: boolean) => void; toggleGrid: (b: boolean) => void; reset: () => void };
|
||||
|
||||
export default function ModelViewer({ depot, name }: { depot: string; name: string }) {
|
||||
export default function ModelViewer({ depot, name, spec, bytes, forceExt }: { depot: string; name: string; spec?: string; bytes?: ArrayBuffer; forceExt?: string }) {
|
||||
const mount = useRef<HTMLDivElement>(null);
|
||||
const api = useRef<Api | null>(null);
|
||||
const [status, setStatus] = useState<"loading" | "ok" | "error">("loading");
|
||||
@ -69,7 +69,7 @@ export default function ModelViewer({ depot, name }: { depot: string; name: stri
|
||||
let gridHelper: THREE.GridHelper | null = null;
|
||||
let home = { pos: new THREE.Vector3(3, 2, 3), tgt: new THREE.Vector3() };
|
||||
|
||||
const ext = (name.toLowerCase().split(".").pop() || "");
|
||||
const ext = (forceExt || name.toLowerCase().split(".").pop() || "");
|
||||
|
||||
function collectStats(obj: THREE.Object3D) {
|
||||
let tris = 0, verts = 0, meshes = 0; const mats = new Set<THREE.Material>();
|
||||
@ -138,7 +138,8 @@ export default function ModelViewer({ depot, name }: { depot: string; name: stri
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const buf = await p4.readDepot(depot);
|
||||
// pre-supplied bytes (e.g. an FBX exported from Unreal) skip the p4 fetch
|
||||
const buf = bytes ? bytes : spec ? await p4.printDepot(spec) : await p4.readDepot(depot);
|
||||
if (disposed) return;
|
||||
const manager = new THREE.LoadingManager();
|
||||
if (ext === "glb" || ext === "gltf") new GLTFLoader(manager).parse(buf, "", (g) => !disposed && place(g.scene), fail);
|
||||
@ -173,7 +174,7 @@ export default function ModelViewer({ depot, name }: { depot: string; name: stri
|
||||
api.current = null;
|
||||
};
|
||||
// eslint-disable-next-line
|
||||
}, [depot, name]);
|
||||
}, [depot, name, spec, bytes, forceExt]);
|
||||
|
||||
const fmt = (n: number) => n.toLocaleString("ru");
|
||||
return (
|
||||
|
||||
117
src/ai.ts
Normal file
@ -0,0 +1,117 @@
|
||||
// OpenRouter-powered commit summary + small avatar helpers.
|
||||
import { p4, OpenedFile, statusOf } from "./p4";
|
||||
import { LANG } from "./i18n";
|
||||
|
||||
// AI replies follow the app's UI language
|
||||
const LANG_NAMES: Record<string, string> = { en: "English", ru: "Russian", de: "German", fr: "French", es: "Spanish" };
|
||||
function langName(): string { return LANG_NAMES[LANG] || "English"; }
|
||||
|
||||
// fixed model — no user-facing AI settings
|
||||
export const DEFAULT_MODEL = "openai/gpt-4o-mini";
|
||||
|
||||
// the key is baked into the binary at build time (src-tauri/openrouter.key)
|
||||
export async function resolveKey(): Promise<string> {
|
||||
try { return (await p4.readAiKey()) || ""; } catch { return ""; }
|
||||
}
|
||||
|
||||
// generate a commit summary + description from the changed files
|
||||
export async function aiSummary(files: OpenedFile[]): Promise<{ summary: string; description: string }> {
|
||||
const key = await resolveKey();
|
||||
if (!key) throw new Error("No OpenRouter API key baked into this build");
|
||||
const list = files.slice(0, 200).map((f) => `${statusOf(f.action).ch} ${f.depotFile || ""}`).join("\n");
|
||||
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", "X-Title": "Exbyte Depot" },
|
||||
body: JSON.stringify({
|
||||
model: DEFAULT_MODEL,
|
||||
max_tokens: 300,
|
||||
temperature: 0.3,
|
||||
messages: [
|
||||
{ role: "system", content: `You write concise commit messages for a Perforce/Unreal project. Given a list of changed files (+ added, ± edited, − deleted), reply with ONE short imperative summary line (under 72 chars, no trailing period), then a blank line, then an optional 1-3 line description. Plain text only — no markdown, no code fences, no quotes. Write the entire reply in ${langName()}.` },
|
||||
{ role: "user", content: `Changed files:\n${list}` },
|
||||
],
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "");
|
||||
throw new Error(`OpenRouter ${res.status}: ${txt.slice(0, 180)}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
const content: string = (data?.choices?.[0]?.message?.content || "").trim();
|
||||
if (!content) throw new Error("Empty AI response");
|
||||
const lines = content.split("\n");
|
||||
const summary = (lines[0] || "").replace(/^["'`*]+|["'`*]+$/g, "").trim();
|
||||
const description = lines.slice(1).join("\n").trim();
|
||||
return { summary, description };
|
||||
}
|
||||
|
||||
// briefly explain what a source file does (2–4 sentences, plain text)
|
||||
export async function aiExplainCode(name: string, code: string): Promise<string> {
|
||||
const key = await resolveKey();
|
||||
if (!key) throw new Error("No OpenRouter API key baked into this build");
|
||||
const snippet = code.slice(0, 14000); // cap the payload
|
||||
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", "X-Title": "Exbyte Depot" },
|
||||
body: JSON.stringify({
|
||||
model: DEFAULT_MODEL,
|
||||
max_tokens: 260,
|
||||
temperature: 0.2,
|
||||
messages: [
|
||||
{ role: "system", content: `You explain source files for a Perforce/Unreal C++ (and general game) project. Given one file, reply with a SHORT plain-text summary (2–4 sentences) of what it does and its role in the project. No markdown, no code fences, no headings, no bullet points — a single tight paragraph. Write the entire reply in ${langName()}.` },
|
||||
{ role: "user", content: `File: ${name}\n\n${snippet}` },
|
||||
],
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "");
|
||||
throw new Error(`OpenRouter ${res.status}: ${txt.slice(0, 180)}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
const content: string = (data?.choices?.[0]?.message?.content || "").trim();
|
||||
if (!content) throw new Error("Empty AI response");
|
||||
return content;
|
||||
}
|
||||
|
||||
// --- persistent cache of AI code summaries (removed only via the × button) ---
|
||||
const EXPL_KEY = "exd-explain";
|
||||
type ExplEntry = { k: string; v: string };
|
||||
function loadExpl(): ExplEntry[] {
|
||||
try { const r = localStorage.getItem(EXPL_KEY); const l = r ? JSON.parse(r) : []; return Array.isArray(l) ? l : []; } catch { return []; }
|
||||
}
|
||||
function storeExpl(list: ExplEntry[]) { try { localStorage.setItem(EXPL_KEY, JSON.stringify(list)); } catch {} }
|
||||
export function getExplain(key: string): string | null {
|
||||
return loadExpl().find((e) => e.k === key)?.v ?? null;
|
||||
}
|
||||
export function saveExplain(key: string, text: string) {
|
||||
const list = loadExpl().filter((e) => e.k !== key);
|
||||
list.push({ k: key, v: text });
|
||||
storeExpl(list.slice(-80)); // keep the 80 most recent
|
||||
}
|
||||
export function dropExplain(key: string) {
|
||||
storeExpl(loadExpl().filter((e) => e.k !== key));
|
||||
}
|
||||
|
||||
// --- avatar helpers ---
|
||||
export function initials(name: string, user: string): string {
|
||||
const n = (name || "").trim();
|
||||
if (n) {
|
||||
const p = n.split(/\s+/);
|
||||
return (p[0][0] + (p[1]?.[0] || "")).toUpperCase();
|
||||
}
|
||||
return (user || "?").slice(0, 2).toUpperCase();
|
||||
}
|
||||
export function avatarColor(seed: string): string {
|
||||
let h = 0;
|
||||
for (let i = 0; i < seed.length; i++) h = (h * 31 + seed.charCodeAt(i)) >>> 0;
|
||||
return `hsl(${h % 360} 52% 52%)`;
|
||||
}
|
||||
// activity level from a unix-epoch last-access string
|
||||
export function activity(access?: string): "active" | "recent" | "idle" {
|
||||
const n = Number(access || 0) * 1000;
|
||||
if (!n) return "idle";
|
||||
const ago = Date.now() - n;
|
||||
if (ago < 60 * 60 * 1000) return "active"; // < 1h
|
||||
if (ago < 24 * 60 * 60 * 1000) return "recent"; // < 1d
|
||||
return "idle";
|
||||
}
|
||||
136
src/i18n.ts
@ -74,10 +74,55 @@ const D: Record<string, Tr> = {
|
||||
"Build Solution — no .sln": { ru: "Собрать решение — нет .sln", de: "Projektmappe bauen — keine .sln", fr: "Compiler — aucune .sln", es: "Compilar — sin .sln" },
|
||||
"No .sln found in the working folder. Choose the project folder first.": { ru: "В рабочей папке нет .sln. Сначала выбери папку проекта.", de: "Keine .sln im Arbeitsordner. Wähle zuerst den Projektordner.", fr: "Aucune .sln dans le dossier de travail. Choisissez d’abord le dossier du projet.", es: "No hay .sln en la carpeta de trabajo. Elige primero la carpeta del proyecto." },
|
||||
"Build": { ru: "Сборка", de: "Build", fr: "Compilation", es: "Compilación" },
|
||||
"AI": { ru: "ИИ", de: "KI", fr: "IA", es: "IA" },
|
||||
"Terminal": { ru: "Терминал", de: "Terminal", fr: "Terminal", es: "Terminal" },
|
||||
"Summary": { ru: "Описание", de: "Zusammenfassung", fr: "Résumé", es: "Resumen" },
|
||||
"AI summary — what this file does": { ru: "ИИ-описание — что делает этот файл", de: "KI-Zusammenfassung — was diese Datei tut", fr: "Résumé IA — ce que fait ce fichier", es: "Resumen con IA — qué hace este archivo" },
|
||||
"Reading the code…": { ru: "Читаю код…", de: "Code wird gelesen…", fr: "Lecture du code…", es: "Leyendo el código…" },
|
||||
"Dismiss": { ru: "Закрыть", de: "Schließen", fr: "Fermer", es: "Cerrar" },
|
||||
"Unreal": { ru: "Unreal", de: "Unreal", fr: "Unreal", es: "Unreal" },
|
||||
"Unreal Log": { ru: "Лог Unreal", de: "Unreal-Log", fr: "Journal Unreal", es: "Registro de Unreal" },
|
||||
"(no output)": { ru: "(нет вывода)", de: "(keine Ausgabe)", fr: "(aucune sortie)", es: "(sin salida)" },
|
||||
"running…": { ru: "выполняется…", de: "läuft…", fr: "en cours…", es: "ejecutando…" },
|
||||
"type a p4 command…": { ru: "введите команду p4…", de: "p4-Befehl eingeben…", fr: "tapez une commande p4…", es: "escribe un comando p4…" },
|
||||
"p4 console — type a command (e.g. opened, changes -m 5, info). ↑/↓ recalls history, Tab completes.": { ru: "Консоль p4 — введите команду (например: opened, changes -m 5, info). ↑/↓ — история, Tab — автодополнение.", de: "p4-Konsole — Befehl eingeben (z. B. opened, changes -m 5, info). ↑/↓ Verlauf, Tab vervollständigt.", fr: "Console p4 — tapez une commande (ex. opened, changes -m 5, info). ↑/↓ historique, Tab complète.", es: "Consola p4 — escribe un comando (p. ej. opened, changes -m 5, info). ↑/↓ historial, Tab autocompleta." },
|
||||
"No Unreal log yet. It shows here when the editor writes to Saved/Logs — i.e. while Unreal is running.": { ru: "Лога Unreal пока нет. Он появится здесь, когда редактор пишет в Saved/Logs — то есть пока Unreal запущен.", de: "Noch kein Unreal-Log. Es erscheint hier, sobald der Editor nach Saved/Logs schreibt — also während Unreal läuft.", fr: "Aucun journal Unreal pour l’instant. Il apparaît ici quand l’éditeur écrit dans Saved/Logs, c.-à-d. quand Unreal tourne.", es: "Aún no hay registro de Unreal. Aparece aquí cuando el editor escribe en Saved/Logs, es decir, mientras Unreal se ejecuta." },
|
||||
"AI commit messages": { ru: "ИИ-сообщения коммитов", de: "KI-Commit-Nachrichten", fr: "Messages de commit IA", es: "Mensajes de commit con IA" },
|
||||
"Draft a commit message with AI from the selected files": { ru: "Сгенерировать сообщение коммита из выбранных файлов через ИИ", de: "Commit-Nachricht per KI aus den gewählten Dateien entwerfen", fr: "Rédiger un message de commit par IA à partir des fichiers sélectionnés", es: "Redactar un mensaje de commit con IA a partir de los archivos seleccionados" },
|
||||
"Select some files first.": { ru: "Сначала выбери файлы.", de: "Wähle zuerst Dateien aus.", fr: "Sélectionnez d’abord des fichiers.", es: "Selecciona primero algunos archivos." },
|
||||
"AI drafted a commit message — review & edit before committing.": { ru: "ИИ подготовил сообщение — проверь и поправь перед коммитом.", de: "KI hat eine Nachricht entworfen — vor dem Commit prüfen und anpassen.", fr: "L’IA a rédigé un message — vérifiez et modifiez avant de valider.", es: "La IA redactó un mensaje — revísalo y edítalo antes de confirmar." },
|
||||
"Set an OpenRouter API key in Settings → AI first.": { ru: "Сначала укажи ключ OpenRouter в Настройках → ИИ.", de: "Zuerst einen OpenRouter-API-Schlüssel unter Einstellungen → KI eintragen.", fr: "Renseignez d’abord une clé API OpenRouter dans Paramètres → IA.", es: "Primero configura una clave API de OpenRouter en Ajustes → IA." },
|
||||
"OpenRouter key": { ru: "Ключ OpenRouter", de: "OpenRouter-Schlüssel", fr: "Clé OpenRouter", es: "Clave OpenRouter" },
|
||||
"Model": { ru: "Модель", de: "Modell", fr: "Modèle", es: "Modelo" },
|
||||
"Hide": { ru: "Скрыть", de: "Verbergen", fr: "Masquer", es: "Ocultar" },
|
||||
"Show": { ru: "Показать", de: "Anzeigen", fr: "Afficher", es: "Mostrar" },
|
||||
"Stored only on this machine. Get a key at openrouter.ai — a “:free” model costs nothing.": { ru: "Хранится только на этом компьютере. Получи ключ на openrouter.ai — модель с «:free» бесплатна.", de: "Nur auf diesem Gerät gespeichert. Schlüssel auf openrouter.ai holen — ein „:free“-Modell kostet nichts.", fr: "Stocké uniquement sur cette machine. Obtenez une clé sur openrouter.ai — un modèle « :free » est gratuit.", es: "Se guarda solo en este equipo. Consigue una clave en openrouter.ai — un modelo « :free » no cuesta nada." },
|
||||
"People & Roles…": { ru: "Пользователи и роли…", de: "Personen & Rollen…", fr: "Personnes et rôles…", es: "Personas y roles…" },
|
||||
"People & Roles": { ru: "Пользователи и роли", de: "Personen & Rollen", fr: "Personnes et rôles", es: "Personas y roles" },
|
||||
"People & Roles — who works on this depot": { ru: "Пользователи и роли — кто работает над депотом", de: "Personen & Rollen — wer an diesem Depot arbeitet", fr: "Personnes et rôles — qui travaille sur ce dépôt", es: "Personas y roles — quién trabaja en este depósito" },
|
||||
"{n} on this depot": { ru: "{n} в депоте", de: "{n} in diesem Depot", fr: "{n} sur ce dépôt", es: "{n} en este depósito" },
|
||||
"Loading users…": { ru: "Загрузка пользователей…", de: "Benutzer werden geladen…", fr: "Chargement des utilisateurs…", es: "Cargando usuarios…" },
|
||||
"Filter people…": { ru: "Фильтр по людям…", de: "Personen filtern…", fr: "Filtrer les personnes…", es: "Filtrar personas…" },
|
||||
"you": { ru: "вы", de: "Sie", fr: "vous", es: "tú" },
|
||||
"No matches.": { ru: "Нет совпадений.", de: "Keine Treffer.", fr: "Aucun résultat.", es: "Sin coincidencias." },
|
||||
"Pick a person to see and edit their roles.": { ru: "Выбери человека, чтобы увидеть и изменить его роли.", de: "Wähle eine Person, um ihre Rollen zu sehen und zu bearbeiten.", fr: "Choisissez une personne pour voir et modifier ses rôles.", es: "Elige a una persona para ver y editar sus roles." },
|
||||
"active {t}": { ru: "активность {t}", de: "aktiv {t}", fr: "actif {t}", es: "activo {t}" },
|
||||
"Roles / groups": { ru: "Роли / группы", de: "Rollen / Gruppen", fr: "Rôles / groupes", es: "Roles / grupos" },
|
||||
"No groups defined on the server.": { ru: "На сервере нет групп.", de: "Auf dem Server sind keine Gruppen definiert.", fr: "Aucun groupe défini sur le serveur.", es: "No hay grupos definidos en el servidor." },
|
||||
"Toggling a group grants or revokes that role. Requires admin rights on the server.": { ru: "Переключение группы выдаёт или отзывает роль. Нужны права администратора на сервере.", de: "Das Umschalten einer Gruppe erteilt oder entzieht die Rolle. Erfordert Admin-Rechte auf dem Server.", fr: "Activer/désactiver un groupe accorde ou retire ce rôle. Nécessite des droits admin sur le serveur.", es: "Activar o desactivar un grupo concede o revoca ese rol. Requiere permisos de administrador en el servidor." },
|
||||
"You need admin rights on the server to change roles.": { ru: "Чтобы менять роли, нужны права администратора на сервере.", de: "Zum Ändern von Rollen sind Admin-Rechte auf dem Server nötig.", fr: "Vous avez besoin de droits admin sur le serveur pour changer les rôles.", es: "Necesitas permisos de administrador en el servidor para cambiar roles." },
|
||||
"Added {u} to “{g}”.": { ru: "{u} добавлен в «{g}».", de: "{u} zu „{g}“ hinzugefügt.", fr: "{u} ajouté à « {g} ».", es: "{u} añadido a « {g} »." },
|
||||
"Removed {u} from “{g}”.": { ru: "{u} удалён из «{g}».", de: "{u} aus „{g}“ entfernt.", fr: "{u} retiré de « {g} ».", es: "{u} eliminado de « {g} »." },
|
||||
"Building…": { ru: "Сборка…", de: "Wird gebaut…", fr: "Compilation…", es: "Compilando…" },
|
||||
"Build succeeded": { ru: "Сборка успешна", de: "Build erfolgreich", fr: "Compilation réussie", es: "Compilación correcta" },
|
||||
"Build failed": { ru: "Сборка провалена", de: "Build fehlgeschlagen", fr: "Échec de la compilation", es: "Compilación fallida" },
|
||||
"Starting MSBuild…": { ru: "Запуск MSBuild…", de: "MSBuild wird gestartet…", fr: "Démarrage de MSBuild…", es: "Iniciando MSBuild…" },
|
||||
"Build configuration": { ru: "Конфигурация сборки", de: "Build-Konfiguration", fr: "Configuration de build", es: "Configuración de compilación" },
|
||||
"Editor build for working in-engine (default)": { ru: "Сборка редактора для работы в движке (по умолчанию)", de: "Editor-Build zum Arbeiten in der Engine (Standard)", fr: "Build éditeur pour travailler dans le moteur (défaut)", es: "Build del editor para trabajar en el motor (predeterminado)" },
|
||||
"Standalone game, dev config": { ru: "Отдельная игра, dev-конфиг", de: "Eigenständiges Spiel, Dev-Konfig", fr: "Jeu autonome, config dev", es: "Juego independiente, config dev" },
|
||||
"Game with debuggable game code": { ru: "Игра с отлаживаемым кодом", de: "Spiel mit debugfähigem Code", fr: "Jeu avec code débogable", es: "Juego con código depurable" },
|
||||
"Optimized release build": { ru: "Оптимизированный релизный билд", de: "Optimierter Release-Build", fr: "Build de release optimisé", es: "Build de lanzamiento optimizado" },
|
||||
"Builds via MSBuild → UnrealBuildTool": { ru: "Сборка через MSBuild → UnrealBuildTool", de: "Build über MSBuild → UnrealBuildTool", fr: "Compile via MSBuild → UnrealBuildTool", es: "Compila con MSBuild → UnrealBuildTool" },
|
||||
"Open this project in Unreal Engine": { ru: "Открыть проект в Unreal Engine", de: "Projekt in Unreal Engine öffnen", fr: "Ouvrir le projet dans Unreal Engine", es: "Abrir el proyecto en Unreal Engine" },
|
||||
"Launching Unreal…": { ru: "Запуск Unreal…", de: "Unreal wird gestartet…", fr: "Lancement d’Unreal…", es: "Iniciando Unreal…" },
|
||||
|
||||
@ -86,6 +131,21 @@ const D: Record<string, Tr> = {
|
||||
"History": { ru: "History", de: "Verlauf", fr: "Historique", es: "Historial" },
|
||||
"Filter {n} files…": { ru: "Фильтр {n} файлов…", de: "{n} Dateien filtern…", fr: "Filtrer {n} fichiers…", es: "Filtrar {n} archivos…" },
|
||||
"{n} changelist(s) committed — ready to Submit": { ru: "{n} changelist(ов) закоммичено — готово к Submit", de: "{n} Changelist(s) committed — bereit zum Submit", fr: "{n} changelist(s) validés — prêts à envoyer", es: "{n} changelist(s) confirmados — listos para enviar" },
|
||||
"Pending · ready to Submit": { ru: "Pending · готово к Submit", de: "Ausstehend · bereit zum Submit", fr: "En attente · prêt à envoyer", es: "Pendiente · listo para enviar" },
|
||||
"Submit all": { ru: "Отправить всё", de: "Alle senden", fr: "Tout envoyer", es: "Enviar todo" },
|
||||
"Submit all to the server": { ru: "Отправить все на сервер", de: "Alle an Server senden", fr: "Tout envoyer au serveur", es: "Enviar todo al servidor" },
|
||||
"Submit": { ru: "Отправить", de: "Senden", fr: "Envoyer", es: "Enviar" },
|
||||
"More": { ru: "Ещё", de: "Mehr", fr: "Plus", es: "Más" },
|
||||
"Submit changelist #{n}?": { ru: "Отправить changelist #{n}?", de: "Changelist #{n} senden?", fr: "Envoyer le changelist #{n} ?", es: "¿Enviar changelist #{n}?" },
|
||||
"This changelist goes to the Perforce server and becomes available to the team. This is a push — it cannot be undone.": { ru: "Этот changelist уйдёт в депо Perforce и станет доступен команде. Это push — отменить нельзя.", de: "Diese Changelist geht an den Perforce-Server und wird für das Team verfügbar. Das ist ein Push — nicht widerrufbar.", fr: "Ce changelist ira sur le serveur Perforce et sera disponible pour l’équipe. C’est un push — irréversible.", es: "Este changelist irá al servidor Perforce y estará disponible para el equipo. Es un push — no se puede deshacer." },
|
||||
"Submitted changelist #{n}.": { ru: "Changelist #{n} отправлен.", de: "Changelist #{n} übermittelt.", fr: "Changelist #{n} envoyé.", es: "Changelist #{n} enviado." },
|
||||
"Uncommit (back to working)": { ru: "Разкоммитить (обратно в Working)", de: "Uncommit (zurück zu Working)", fr: "Décommiter (retour au travail)", es: "Descomitear (volver a trabajo)" },
|
||||
"Moved back to working changes.": { ru: "Возвращено в текущие изменения.", de: "Zurück zu den Arbeitsänderungen verschoben.", fr: "Renvoyé aux modifications en cours.", es: "Devuelto a los cambios de trabajo." },
|
||||
"Edit description": { ru: "Изменить описание", de: "Beschreibung bearbeiten", fr: "Modifier la description", es: "Editar descripción" },
|
||||
"Changelist #{n}": { ru: "Changelist #{n}", de: "Changelist #{n}", fr: "Changelist #{n}", es: "Changelist #{n}" },
|
||||
"Description updated.": { ru: "Описание обновлено.", de: "Beschreibung aktualisiert.", fr: "Description mise à jour.", es: "Descripción actualizada." },
|
||||
"(files are outside the current working folder)": { ru: "(файлы вне текущей рабочей папки)", de: "(Dateien außerhalb des Arbeitsordners)", fr: "(fichiers hors du dossier de travail)", es: "(archivos fuera de la carpeta de trabajo)" },
|
||||
"Save": { ru: "Сохранить", de: "Speichern", fr: "Enregistrer", es: "Guardar" },
|
||||
"Scanning disk for changes…": { ru: "Ищу изменения на диске…", de: "Suche Änderungen auf der Festplatte…", fr: "Recherche de modifications sur le disque…", es: "Buscando cambios en el disco…" },
|
||||
" for the whole workspace this is slow — pick a project folder above": { ru: " для всего workspace это долго — выбери рабочую папку проекта сверху", de: " für den ganzen Workspace ist das langsam — wähle oben einen Projektordner", fr: " pour tout le workspace c’est lent — choisissez un dossier de projet en haut", es: " para todo el workspace es lento — elige una carpeta de proyecto arriba" },
|
||||
"{a} of {b} selected": { ru: "{a} из {b} выбрано", de: "{a} von {b} ausgewählt", fr: "{a} sur {b} sélectionnés", es: "{a} de {b} seleccionados" },
|
||||
@ -142,7 +202,13 @@ const D: Record<string, Tr> = {
|
||||
"Local changes to {name} will be lost permanently — the file returns to the server version.": { ru: "Локальные изменения в {name} будут потеряны безвозвратно — файл вернётся к версии с сервера.", de: "Lokale Änderungen an {name} gehen endgültig verloren — die Datei kehrt zur Serverversion zurück.", fr: "Les modifications locales de {name} seront perdues définitivement — le fichier revient à la version du serveur.", es: "Los cambios locales en {name} se perderán para siempre — el archivo vuelve a la versión del servidor." },
|
||||
"Discarded: {name}": { ru: "Отменено: {name}", de: "Verworfen: {name}", fr: "Abandonné : {name}", es: "Descartado: {name}" },
|
||||
"Edit in VS Code": { ru: "Открыть в VS Code", de: "In VS Code öffnen", fr: "Ouvrir dans VS Code", es: "Abrir en VS Code" },
|
||||
"Open in VS Code": { ru: "Открыть в VS Code", de: "In VS Code öffnen", fr: "Ouvrir dans VS Code", es: "Abrir en VS Code" },
|
||||
"Opening in VS Code…": { ru: "Открываю в VS Code…", de: "Wird in VS Code geöffnet…", fr: "Ouverture dans VS Code…", es: "Abriendo en VS Code…" },
|
||||
"Open in {editor}": { ru: "Открыть в {editor}", de: "In {editor} öffnen", fr: "Ouvrir dans {editor}", es: "Abrir en {editor}" },
|
||||
"Opening in {editor}…": { ru: "Открываю в {editor}…", de: "Wird in {editor} geöffnet…", fr: "Ouverture dans {editor}…", es: "Abriendo en {editor}…" },
|
||||
"Code editor": { ru: "Редактор кода", de: "Code-Editor", fr: "Éditeur de code", es: "Editor de código" },
|
||||
"Open code files in this editor": { ru: "Открывать файлы кода в этом редакторе", de: "Code-Dateien in diesem Editor öffnen", fr: "Ouvrir les fichiers de code dans cet éditeur", es: "Abrir archivos de código en este editor" },
|
||||
"System default": { ru: "Системный по умолчанию", de: "Systemstandard", fr: "Application par défaut", es: "Predeterminado del sistema" },
|
||||
"Revert changelist #{n}?": { ru: "Откатить changelist #{n}?", de: "Changelist #{n} zurücksetzen?", fr: "Annuler le changelist #{n} ?", es: "¿Revertir changelist #{n}?" },
|
||||
"Perforce will create a NEW changelist that undoes #{n} and submit it to the server. History is kept (like “Revert” in GitHub Desktop).\n\n“{desc}”": { ru: "Perforce создаст НОВЫЙ changelist, отменяющий изменения из #{n}, и засабмитит его на сервер. История сохранится (как «Revert» в GitHub Desktop).\n\n«{desc}»", de: "Perforce erstellt eine NEUE Changelist, die #{n} rückgängig macht, und sendet sie an den Server. Der Verlauf bleibt erhalten (wie „Revert“ in GitHub Desktop).\n\n„{desc}“", fr: "Perforce créera un NOUVEAU changelist qui annule #{n} et l’enverra au serveur. L’historique est conservé (comme « Revert » dans GitHub Desktop).\n\n« {desc} »", es: "Perforce creará un NUEVO changelist que deshace #{n} y lo enviará al servidor. El historial se conserva (como «Revert» en GitHub Desktop).\n\n«{desc}»" },
|
||||
"Revert #{n}": { ru: "Откатить #{n}", de: "#{n} zurücksetzen", fr: "Annuler #{n}", es: "Revertir #{n}" },
|
||||
@ -206,6 +272,8 @@ const D: Record<string, Tr> = {
|
||||
"(no description)": { ru: "(без описания)", de: "(keine Beschreibung)", fr: "(sans description)", es: "(sin descripción)" },
|
||||
"{user} · {time} · {n} file(s)": { ru: "{user} · {time} · {n} файл(ов)", de: "{user} · {time} · {n} Datei(en)", fr: "{user} · {time} · {n} fichier(s)", es: "{user} · {time} · {n} archivo(s)" },
|
||||
"Loading file list…": { ru: "Загрузка списка файлов…", de: "Dateiliste wird geladen…", fr: "Chargement de la liste des fichiers…", es: "Cargando lista de archivos…" },
|
||||
"Back to file list": { ru: "Назад к списку файлов", de: "Zurück zur Dateiliste", fr: "Retour à la liste des fichiers", es: "Volver a la lista de archivos" },
|
||||
"in #{n}": { ru: "в #{n}", de: "in #{n}", fr: "dans #{n}", es: "en #{n}" },
|
||||
// history list
|
||||
"Loading history…": { ru: "Загрузка истории…", de: "Verlauf wird geladen…", fr: "Chargement de l’historique…", es: "Cargando historial…" },
|
||||
"No submitted changes.": { ru: "Нет засабмиченных изменений.", de: "Keine übermittelten Änderungen.", fr: "Aucune modification envoyée.", es: "No hay cambios enviados." },
|
||||
@ -258,6 +326,74 @@ const D: Record<string, Tr> = {
|
||||
"Downloading… {pct}%": { ru: "Загрузка… {pct}%", de: "Wird geladen… {pct}%", fr: "Téléchargement… {pct}%", es: "Descargando… {pct}%" },
|
||||
"Installed — restarting…": { ru: "Установлено — перезапускаю…", de: "Installiert — Neustart…", fr: "Installé — redémarrage…", es: "Instalado — reiniciando…" },
|
||||
"Update now": { ru: "Обновить сейчас", de: "Jetzt aktualisieren", fr: "Mettre à jour", es: "Actualizar ahora" },
|
||||
|
||||
// collaboration / advanced perforce
|
||||
"Lock (exclusive)": { ru: "Заблокировать (эксклюзивно)", de: "Sperren (exklusiv)", fr: "Verrouiller (exclusif)", es: "Bloquear (exclusivo)" },
|
||||
"Unlock": { ru: "Разблокировать", de: "Entsperren", fr: "Déverrouiller", es: "Desbloquear" },
|
||||
"Locked {n} file(s).": { ru: "Заблокировано файлов: {n}.", de: "{n} Datei(en) gesperrt.", fr: "{n} fichier(s) verrouillé(s).", es: "{n} archivo(s) bloqueado(s)." },
|
||||
"Unlocked {n} file(s).": { ru: "Разблокировано файлов: {n}.", de: "{n} Datei(en) entsperrt.", fr: "{n} fichier(s) déverrouillé(s).", es: "{n} archivo(s) desbloqueado(s)." },
|
||||
"Locked by {u}": { ru: "Заблокировал {u}", de: "Gesperrt von {u}", fr: "Verrouillé par {u}", es: "Bloqueado por {u}" },
|
||||
"Also open by {u}": { ru: "Также открыт у {u}", de: "Auch offen bei {u}", fr: "Aussi ouvert par {u}", es: "También abierto por {u}" },
|
||||
"Move to #{n}": { ru: "Переместить в #{n}", de: "Verschieben nach #{n}", fr: "Déplacer vers #{n}", es: "Mover a #{n}" },
|
||||
"Moved {n} file(s) to #{c}.": { ru: "Перемещено файлов {n} в #{c}.", de: "{n} Datei(en) nach #{c} verschoben.", fr: "{n} fichier(s) déplacé(s) vers #{c}.", es: "{n} archivo(s) movido(s) a #{c}." },
|
||||
"Blame (annotate)": { ru: "Авторы строк (annotate)", de: "Zeilen-Autoren (annotate)", fr: "Blâme (annotate)", es: "Autoría (annotate)" },
|
||||
"Blame": { ru: "Авторы строк", de: "Zeilen-Autoren", fr: "Blâme", es: "Autoría" },
|
||||
"Blame — who changed each line": { ru: "Авторы строк — кто менял каждую строку", de: "Wer welche Zeile geändert hat", fr: "Qui a modifié chaque ligne", es: "Quién cambió cada línea" },
|
||||
"Diff against the previous revision": { ru: "Сравнить с предыдущей ревизией", de: "Mit vorheriger Revision vergleichen", fr: "Comparer à la révision précédente", es: "Comparar con la revisión anterior" },
|
||||
"No earlier revision to compare.": { ru: "Нет предыдущей ревизии для сравнения.", de: "Keine frühere Revision zum Vergleichen.", fr: "Aucune révision antérieure à comparer.", es: "No hay revisión anterior que comparar." },
|
||||
"(files are identical)": { ru: "(файлы идентичны)", de: "(Dateien sind identisch)", fr: "(fichiers identiques)", es: "(archivos idénticos)" },
|
||||
"Shelve (share on server)": { ru: "Отложить (shelve, на сервере)", de: "Shelve (auf Server teilen)", fr: "Remiser (shelve, sur serveur)", es: "Archivar (shelve, en servidor)" },
|
||||
"Unshelve into workspace": { ru: "Вернуть из shelve в рабочую копию", de: "Aus Shelve zurückholen", fr: "Récupérer depuis le shelve", es: "Recuperar del shelve" },
|
||||
"Delete shelved files": { ru: "Удалить отложенные файлы", de: "Shelve-Dateien löschen", fr: "Supprimer les fichiers remisés", es: "Eliminar archivos archivados" },
|
||||
"Shelved #{n} on the server.": { ru: "Отложено #{n} на сервере.", de: "#{n} auf dem Server geshelved.", fr: "#{n} remisé sur le serveur.", es: "#{n} archivado en el servidor." },
|
||||
"Unshelved #{n} into the workspace.": { ru: "Возвращено #{n} в рабочую копию.", de: "#{n} in den Arbeitsbereich zurückgeholt.", fr: "#{n} récupéré dans l'espace de travail.", es: "#{n} recuperado al espacio de trabajo." },
|
||||
"Delete shelved files?": { ru: "Удалить отложенные файлы?", de: "Shelve-Dateien löschen?", fr: "Supprimer les fichiers remisés ?", es: "¿Eliminar archivos archivados?" },
|
||||
"The shelved copy of #{n} on the server will be removed. Your local files are untouched.": { ru: "Отложенная копия #{n} на сервере будет удалена. Локальные файлы не тронуты.", de: "Die Shelve-Kopie von #{n} auf dem Server wird entfernt. Lokale Dateien bleiben unberührt.", fr: "La copie remisée de #{n} sur le serveur sera supprimée. Vos fichiers locaux sont intacts.", es: "La copia archivada de #{n} en el servidor se eliminará. Tus archivos locales quedan intactos." },
|
||||
"Delete shelf": { ru: "Удалить shelve", de: "Shelve löschen", fr: "Supprimer le shelve", es: "Eliminar shelve" },
|
||||
"Deleted shelved files of #{n}.": { ru: "Удалены отложенные файлы #{n}.", de: "Shelve-Dateien von #{n} gelöscht.", fr: "Fichiers remisés de #{n} supprimés.", es: "Archivos archivados de #{n} eliminados." },
|
||||
"Search depot…": { ru: "Поиск в депо…", de: "Depot durchsuchen…", fr: "Rechercher dans le dépôt…", es: "Buscar en el depósito…" },
|
||||
"Search depot": { ru: "Поиск в депо", de: "Depot durchsuchen", fr: "Recherche dans le dépôt", es: "Buscar en el depósito" },
|
||||
"Type at least 2 characters…": { ru: "Введите минимум 2 символа…", de: "Mindestens 2 Zeichen eingeben…", fr: "Saisissez au moins 2 caractères…", es: "Escribe al menos 2 caracteres…" },
|
||||
"Searching…": { ru: "Поиск…", de: "Suche…", fr: "Recherche…", es: "Buscando…" },
|
||||
"Open in editor": { ru: "Открыть в редакторе", de: "Im Editor öffnen", fr: "Ouvrir dans l'éditeur", es: "Abrir en el editor" },
|
||||
"Resolve conflicts": { ru: "Разрешить конфликты", de: "Konflikte auflösen", fr: "Résoudre les conflits", es: "Resolver conflictos" },
|
||||
"Resolve conflicts ({n})": { ru: "Разрешить конфликты ({n})", de: "Konflikte auflösen ({n})", fr: "Résoudre les conflits ({n})", es: "Resolver conflictos ({n})" },
|
||||
"Resolve…": { ru: "Разрешить…", de: "Auflösen…", fr: "Résoudre…", es: "Resolver…" },
|
||||
"{n} file(s) need conflict resolution after sync.": { ru: "Файлов с конфликтами после синка: {n}.", de: "{n} Datei(en) benötigen nach Sync eine Konfliktauflösung.", fr: "{n} fichier(s) nécessitent une résolution après la synchro.", es: "{n} archivo(s) necesitan resolución tras la sincronización." },
|
||||
"Auto-merge all": { ru: "Авто-слияние всех", de: "Alle automatisch zusammenführen", fr: "Fusion auto de tout", es: "Fusión automática de todo" },
|
||||
"Accept theirs (all)": { ru: "Принять их (все)", de: "Ihre übernehmen (alle)", fr: "Accepter les leurs (tout)", es: "Aceptar los suyos (todo)" },
|
||||
"Accept yours (all)": { ru: "Принять свои (все)", de: "Meine übernehmen (alle)", fr: "Accepter les miens (tout)", es: "Aceptar los míos (todo)" },
|
||||
"Auto-merge": { ru: "Авто-слияние", de: "Auto-Merge", fr: "Fusion auto", es: "Fusión automática" },
|
||||
"Accept theirs": { ru: "Принять их", de: "Ihre übernehmen", fr: "Accepter les leurs", es: "Aceptar los suyos" },
|
||||
"Accept yours": { ru: "Принять свои", de: "Meine übernehmen", fr: "Accepter les miens", es: "Aceptar los míos" },
|
||||
"Merge": { ru: "Слить", de: "Zusammenführen", fr: "Fusionner", es: "Fusionar" },
|
||||
"Theirs": { ru: "Их", de: "Ihre", fr: "Les leurs", es: "Suyos" },
|
||||
"Yours": { ru: "Свои", de: "Meine", fr: "Les miens", es: "Míos" },
|
||||
"All conflicts resolved.": { ru: "Все конфликты разрешены.", de: "Alle Konflikte aufgelöst.", fr: "Tous les conflits résolus.", es: "Todos los conflictos resueltos." },
|
||||
"Nothing to resolve.": { ru: "Нечего разрешать.", de: "Nichts aufzulösen.", fr: "Rien à résoudre.", es: "Nada que resolver." },
|
||||
"Not a text file.": { ru: "Не текстовый файл.", de: "Keine Textdatei.", fr: "Pas un fichier texte.", es: "No es un archivo de texto." },
|
||||
"{user} submitted #{n}": { ru: "{user} отправил #{n}", de: "{user} hat #{n} übermittelt", fr: "{user} a soumis #{n}", es: "{user} envió #{n}" },
|
||||
"Reconnected to the server.": { ru: "Соединение с сервером восстановлено.", de: "Wieder mit dem Server verbunden.", fr: "Reconnecté au serveur.", es: "Reconectado al servidor." },
|
||||
"Connected server": { ru: "Сервер подключения", de: "Verbundener Server", fr: "Serveur connecté", es: "Servidor conectado" },
|
||||
"No embedded thumbnail. Load the 3D model below (needs Unreal Engine installed).": { ru: "Встроенного превью нет. Загрузи 3D-модель ниже (нужен установленный Unreal Engine).", de: "Kein eingebettetes Vorschaubild. Lade unten das 3D-Modell (Unreal Engine muss installiert sein).", fr: "Pas de miniature intégrée. Charge le modèle 3D ci-dessous (Unreal Engine requis).", es: "Sin miniatura incrustada. Carga el modelo 3D abajo (requiere Unreal Engine instalado)." },
|
||||
"Load 3D model": { ru: "Загрузить 3D-модель", de: "3D-Modell laden", fr: "Charger le modèle 3D", es: "Cargar modelo 3D" },
|
||||
"Exporting from Unreal… (first time is slow)": { ru: "Экспорт из Unreal… (первый раз долго)", de: "Export aus Unreal… (erstes Mal langsam)", fr: "Export depuis Unreal… (lent la première fois)", es: "Exportando desde Unreal… (la primera vez es lento)" },
|
||||
"Export the mesh from Unreal and view it in 3D": { ru: "Экспортировать меш из Unreal и посмотреть в 3D", de: "Mesh aus Unreal exportieren und in 3D ansehen", fr: "Exporter le mesh depuis Unreal et le voir en 3D", es: "Exportar el mesh desde Unreal y verlo en 3D" },
|
||||
"3D export works on the working copy, not history": { ru: "3D-экспорт работает по рабочей копии, не по истории", de: "3D-Export funktioniert mit der Arbeitskopie, nicht mit der Historie", fr: "L'export 3D fonctionne sur la copie de travail, pas l'historique", es: "La exportación 3D funciona con la copia de trabajo, no con el historial" },
|
||||
"Exports the version currently synced on disk.": { ru: "Экспортирует версию, которая сейчас синкнута на диске.", de: "Exportiert die aktuell auf der Festplatte synchronisierte Version.", fr: "Exporte la version actuellement synchronisée sur le disque.", es: "Exporta la versión actualmente sincronizada en disco." },
|
||||
"Editing": { ru: "Редактирование", de: "Bearbeiten", fr: "Édition", es: "Editando" },
|
||||
"Edit": { ru: "Редактировать", de: "Bearbeiten", fr: "Modifier", es: "Editar" },
|
||||
"Saved {name}": { ru: "Сохранено: {name}", de: "Gespeichert: {name}", fr: "Enregistré : {name}", es: "Guardado: {name}" },
|
||||
"File Locks…": { ru: "Блокировки файлов…", de: "Dateisperren…", fr: "Verrous de fichiers…", es: "Bloqueos de archivos…" },
|
||||
"File Locks": { ru: "Блокировки файлов", de: "Dateisperren", fr: "Verrous de fichiers", es: "Bloqueos de archivos" },
|
||||
"{n} open · {m} locked": { ru: "{n} открыто · {m} залочено", de: "{n} offen · {m} gesperrt", fr: "{n} ouverts · {m} verrouillés", es: "{n} abiertos · {m} bloqueados" },
|
||||
"Filter by file or user…": { ru: "Фильтр по файлу или пользователю…", de: "Nach Datei oder Benutzer filtern…", fr: "Filtrer par fichier ou utilisateur…", es: "Filtrar por archivo o usuario…" },
|
||||
"Locked only": { ru: "Только залоченные", de: "Nur gesperrte", fr: "Verrouillés uniquement", es: "Solo bloqueados" },
|
||||
"All open": { ru: "Все открытые", de: "Alle offenen", fr: "Tous ouverts", es: "Todos abiertos" },
|
||||
"No locked files.": { ru: "Залоченных файлов нет.", de: "Keine gesperrten Dateien.", fr: "Aucun fichier verrouillé.", es: "No hay archivos bloqueados." },
|
||||
"No files open by anyone.": { ru: "Ни у кого нет открытых файлов.", de: "Niemand hat Dateien geöffnet.", fr: "Personne n'a de fichiers ouverts.", es: "Nadie tiene archivos abiertos." },
|
||||
"Pick the Unreal project working folder first.": { ru: "Сначала выбери рабочую папку Unreal-проекта.", de: "Wähle zuerst den Arbeitsordner des Unreal-Projekts.", fr: "Choisis d'abord le dossier de travail du projet Unreal.", es: "Primero elige la carpeta de trabajo del proyecto Unreal." },
|
||||
"Disconnected from the server — retrying…": { ru: "Соединение с сервером потеряно — переподключаюсь…", de: "Verbindung zum Server verloren — erneuter Versuch…", fr: "Déconnecté du serveur — nouvelle tentative…", es: "Desconectado del servidor — reintentando…" },
|
||||
};
|
||||
|
||||
export function t(en: string, vars?: Record<string, string | number>): string {
|
||||
|
||||
82
src/p4.ts
@ -22,9 +22,16 @@ export interface OpenedFile {
|
||||
change?: string; // "default" or a number
|
||||
user?: string;
|
||||
client?: string;
|
||||
_spec?: string; // when set, read this exact rev via `p4 print` (submitted/historical file)
|
||||
[k: string]: unknown;
|
||||
}
|
||||
|
||||
// choose the byte source: an exact submitted revision (`p4 print`) when `_spec`
|
||||
// is set, otherwise the working copy (local file via `p4 where`).
|
||||
export function fileBytes(f: OpenedFile): Promise<ArrayBuffer> {
|
||||
return f._spec ? p4.printDepot(f._spec) : p4.readDepot(f.depotFile || "");
|
||||
}
|
||||
|
||||
export interface Client {
|
||||
client?: string;
|
||||
Root?: string;
|
||||
@ -64,6 +71,8 @@ export const p4 = {
|
||||
commit: (description: string, files: string[]) =>
|
||||
invoke<string>("p4_commit", { description, files }),
|
||||
submitChange: (change: string) => invoke<string>("p4_submit_change", { change }),
|
||||
reopenDefault: (files: string[]) => invoke<string>("p4_reopen_default", { files }),
|
||||
setDesc: (change: string, description: string) => invoke<string>("p4_set_desc", { change, description }),
|
||||
add: (files: string[]) => invoke<OpenedFile[]>("p4_add", { files }),
|
||||
edit: (files: string[]) => invoke<OpenedFile[]>("p4_edit", { files }),
|
||||
del: (files: string[]) => invoke<OpenedFile[]>("p4_delete", { files }),
|
||||
@ -74,9 +83,10 @@ export const p4 = {
|
||||
openInExplorer: (target: string) => invoke<void>("open_in_explorer", { target }),
|
||||
findUproject: (scope: string) => invoke<string>("find_uproject", { scope }),
|
||||
findSln: (scope: string) => invoke<string>("find_sln", { scope }),
|
||||
buildSln: (path: string) => invoke<string>("build_sln", { path }),
|
||||
buildSln: (path: string, config: string) => invoke<string>("build_sln", { path, config }),
|
||||
launchFile: (path: string) => invoke<void>("launch_file", { path }),
|
||||
openInVscode: (depot: string) => invoke<void>("open_in_vscode", { depot }),
|
||||
listEditors: () => invoke<Editor[]>("list_editors"),
|
||||
openInEditor: (depot: string, editor: string) => invoke<void>("open_in_editor", { depot, editor }),
|
||||
readDepot: async (depot: string): Promise<ArrayBuffer> => {
|
||||
const r: unknown = await invoke("read_depot", { depot });
|
||||
if (r instanceof ArrayBuffer) return r;
|
||||
@ -84,9 +94,67 @@ export const p4 = {
|
||||
if (Array.isArray(r)) return new Uint8Array(r as number[]).buffer;
|
||||
return r as ArrayBuffer;
|
||||
},
|
||||
// read raw bytes at an exact revision (submitted/historical), e.g. "//depot/f.cpp#7"
|
||||
printDepot: async (spec: string): Promise<ArrayBuffer> => {
|
||||
const r: unknown = await invoke("print_depot", { spec });
|
||||
if (r instanceof ArrayBuffer) return r;
|
||||
if (ArrayBuffer.isView(r)) return (r as ArrayBufferView).buffer as ArrayBuffer;
|
||||
if (Array.isArray(r)) return new Uint8Array(r as number[]).buffer;
|
||||
return r as ArrayBuffer;
|
||||
},
|
||||
// built-in editor: write text back to a depot file's working copy
|
||||
writeDepot: (depot: string, content: string) => invoke<void>("write_depot", { depot, content }),
|
||||
switchClient: (client: string) => invoke<P4Info>("p4_switch_client", { client }),
|
||||
users: () => invoke<User[]>("p4_users"),
|
||||
groups: (user = "") => invoke<{ group?: string }[]>("p4_groups", { user }),
|
||||
groupMember: (group: string, user: string, add: boolean) => invoke<string>("p4_group_member", { group, user, add }),
|
||||
readAiKey: () => invoke<string>("read_ai_key"),
|
||||
saveAiKey: (key: string) => invoke<void>("save_ai_key", { key }),
|
||||
// run an arbitrary p4 command in the built-in terminal (combined stdout+stderr)
|
||||
console: (args: string[]) => invoke<string>("p4_console", { args }),
|
||||
// tail the newest Unreal log for a project (.uproject path → Saved/Logs/*.log)
|
||||
ueLog: (uproject: string, tailBytes = 200_000) => invoke<string>("ue_log", { uproject, tailBytes }),
|
||||
// --- collaboration / advanced Perforce ---
|
||||
openedAll: (scope = "") => invoke<OpenedFile[]>("p4_opened_all", { scope }),
|
||||
lock: (files: string[]) => invoke<string>("p4_lock", { files }),
|
||||
unlock: (files: string[]) => invoke<string>("p4_unlock", { files }),
|
||||
shelve: (change: string) => invoke<string>("p4_shelve", { change }),
|
||||
unshelve: (change: string) => invoke<string>("p4_unshelve", { change }),
|
||||
deleteShelf: (change: string) => invoke<string>("p4_delete_shelf", { change }),
|
||||
shelvedFiles: (change: string) => invoke<Describe>("p4_shelved_files", { change }),
|
||||
resolveList: () => invoke<OpenedFile[]>("p4_resolve_list"),
|
||||
resolveFile: (file: string, mode: "am" | "ay" | "at") => invoke<string>("p4_resolve_file", { file, mode }),
|
||||
diff2: (spec1: string, spec2: string) => invoke<string>("p4_diff2", { spec1, spec2 }),
|
||||
annotate: (spec: string) => invoke<string>("p4_annotate", { spec }),
|
||||
searchFiles: (query: string, scope = "") => invoke<{ depotFile?: string; rev?: string }[]>("p4_search_files", { query, scope }),
|
||||
reopenTo: (change: string, files: string[]) => invoke<string>("p4_reopen_to", { change, files }),
|
||||
latestChange: (scope = "") => invoke<Change | null>("p4_latest_change", { scope }),
|
||||
// embedded Unreal .uasset thumbnail (PNG bytes), for working copy or history
|
||||
uassetThumb: async (spec: string, historical: boolean): Promise<ArrayBuffer> => {
|
||||
const r: unknown = await invoke("uasset_thumbnail", { spec, historical });
|
||||
if (r instanceof ArrayBuffer) return r;
|
||||
if (ArrayBuffer.isView(r)) return (r as ArrayBufferView).buffer as ArrayBuffer;
|
||||
if (Array.isArray(r)) return new Uint8Array(r as number[]).buffer;
|
||||
return r as ArrayBuffer;
|
||||
},
|
||||
// export a working-copy .uasset mesh to FBX via headless Unreal → real 3D
|
||||
exportUasset3d: async (depot: string, uproject: string): Promise<ArrayBuffer> => {
|
||||
const r: unknown = await invoke("uasset_export_3d", { depot, uproject });
|
||||
if (r instanceof ArrayBuffer) return r;
|
||||
if (ArrayBuffer.isView(r)) return (r as ArrayBufferView).buffer as ArrayBuffer;
|
||||
if (Array.isArray(r)) return new Uint8Array(r as number[]).buffer;
|
||||
return r as ArrayBuffer;
|
||||
},
|
||||
};
|
||||
|
||||
export interface User { User?: string; Email?: string; FullName?: string; Access?: string; Update?: string; [k: string]: unknown }
|
||||
|
||||
// a detected code editor / IDE
|
||||
export interface Editor { id: string; name: string; exe: string; args: string[] }
|
||||
// which editor to open code files in (persisted; "" → auto-pick)
|
||||
export function getEditor(): string { try { return localStorage.getItem("exd-editor") || ""; } catch { return ""; } }
|
||||
export function setEditorPref(id: string) { try { localStorage.setItem("exd-editor", id); } catch {} }
|
||||
|
||||
export interface Dir { dir?: string; [k: string]: unknown }
|
||||
// depot path -> short leaf name (last segment)
|
||||
export function leaf(path?: string): string {
|
||||
@ -162,3 +230,13 @@ export function kindOf(name: string): Kind {
|
||||
if (/\.(png|jpe?g|webp|gif|bmp|svg)$/.test(n)) return "image";
|
||||
return "code";
|
||||
}
|
||||
|
||||
// true only for real text/source files — gates the "Summary" and "Open in IDE"
|
||||
// buttons so they never appear on binaries (.pak/.dll/.exe/.bin/etc.) that
|
||||
// happen to fall through kindOf() as "code".
|
||||
const CODE_EXT = /\.(c|cc|cpp|cxx|h|hpp|hh|inl|cs|ts|tsx|js|jsx|mjs|cjs|py|rs|go|java|kt|kts|swift|m|mm|rb|php|lua|sh|bash|ps1|bat|cmd|sql|json|jsonc|yaml|yml|toml|ini|cfg|conf|xml|html|htm|css|scss|sass|less|md|markdown|txt|log|csv|gitignore|editorconfig|uproject|uplugin|build|target|usf|ush|hlsl|glsl|shader|cginc|props|targets|sln|vcxproj|csproj|gradle|cmake|make|mk)$/i;
|
||||
export function isCodeFile(name: string): boolean {
|
||||
const n = name.toLowerCase();
|
||||
if (/\.(uasset|umap|pak|exe|dll|so|dylib|bin|obj|lib|a|pdb|zip|7z|rar|gz|tar|png|jpe?g|gif|bmp|webp|svg|fbx|obj|gltf|glb|stl|ply|dae|wav|mp3|ogg|mp4|mov|ttf|otf)$/i.test(n)) return false;
|
||||
return CODE_EXT.test(n);
|
||||
}
|
||||
|
||||