18 Commits

Author SHA1 Message Date
5652f489a2 v0.3.1 — view-panel tabs, tray + notifications, server-activity, media preview, dockable locks
- Tabbed view panel: File Viewer / File Tree / File Locks, header hidden at one tab, re-openable from Window, state persisted
- Right-click Workspace/Working-folder zones -> Show in File Tree (focuses the right side)
- Commit weight shown in History (p4 sizes -s //...@=CH)
- Rich code editor (highlighted underlay + gutter) and clean spec-editor surfaces (no more grey notepad)
- System tray (close-to-tray, tray menu Quit) + native OS notifications for teammate submits, even when hidden
- 'You're behind the server' banner + native notification (p4 sync -n), rechecked only when the server advances
- Audio & video preview with playback (media-src blob CSP); File Locks dockable into the view panel
- Backend: depot_ls/fs_ls, p4_change_sizes, p4_behind; tauri-plugin-notification + tray-icon

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 14:26:22 +03:00
9c24d45bf9 Files & Sizes explorer: browse Depot & Workspace as a tree with per-file/folder weight
- Backend depot_ls (p4 dirs + p4 sizes -s recursive summary per child + p4 sizes for direct files) and fs_ls (local recursive folder sizing), both confined
- Tools -> Files & Sizes: Depot/Workspace tabs, lazy-loading tree, size bars, heaviest-first sort, folder file counts, open-in-explorer
- humanSize() helper + FsEntry type; i18n in 5 languages

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 12:56:31 +03:00
ec8f31c654 Offline Work mode + Reconcile Offline Work dialog, menu tooltips, spinner fix; v0.3.0
- Explicit Work Offline toggle (P4V-style): stops server polling, calm offline banner, persisted
- Reconcile Offline Work dialog: preview adds/edits/deletes with per-file checkboxes, opens picked into a changelist
- Make writable (offline edit) context action; backend p4_reconcile_preview/apply + p4_set_writable
- Tooltips (title) on every top-menu item, 5 languages
- Fix .ldr spinner rendering (inline-block + box-sizing) so it's a clean circle everywhere

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 12:46:37 +03:00
5d647f8e92 Submit options (-r / revert-unchanged / -e shelved) + .p4ignore editor
- Pending changelist menu: Submit & keep checked out (-r), Submit reverting
  unchanged, Submit shelved (-e)
- Tools -> Edit .p4ignore: read/write workspace .p4ignore with an Unreal template
- Backend p4 submit -r/-f revertunchanged/-e, .p4ignore read/write

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 12:09:12 +03:00
28a3989095 Clean workspace (make it match the depot)
- Actions -> Clean workspace: preview extra/deleted/modified files, then apply
  p4 clean (opened files untouched)
- Backend p4 clean -n / clean

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 12:06:11 +03:00
ecc5b8e9bf File history timeline (filelog) with diff-to-previous
- File history button in the preview header opens a per-file revision timeline
  (rev, changelist, action, user, date, description) with Diff to previous
- Backend p4 filelog -l; filelogRevs parser

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 12:03:32 +03:00
2069f0fed4 Jobs: list / create / edit + attach to changelist
- Tools -> Jobs: browse/filter jobs, create/edit job spec
- Pending changelist menu -> Attach job... (p4 fix)
- Backend p4 jobs, fix, job -o/-i

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 11:59:34 +03:00
7092c9d1dc Streams: list / switch / merge down / copy up
- Tools -> Streams: list server streams, switch the workspace to a stream,
  merge down from parent / copy up to parent (into a pending changelist)
- Backend p4 streams, client -s -S, merge/copy -S

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 11:56:21 +03:00
fdaf126c00 Integrate / Merge / Copy between branches
- Tools -> Integrate / Merge / Copy: pick op + source/target paths, runs into a
  new pending changelist; resolve (if needed) via the Resolve UI and submit
- Backend p4 merge/copy/integrate -c

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 11:53:12 +03:00
2811aec6e8 Labels: list, tag current, sync-to
- Tools -> Labels: list server labels, create/tag a label at current head,
  one-click Sync workspace to a label (via Get Revision)
- Backend p4 labels, p4 tag -l

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 11:49:18 +03:00
6cfeb27bdd Workspace (client) spec create + edit
- File -> New workspace... (name -> template -> edit -> create & switch)
- File -> Edit current workspace... (edit View/Root/Options as spec text)
- Backend p4 client -o/-i

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 11:46:47 +03:00
0fc0af38da Get Revision: sync workspace to a changelist / label / date
- Actions -> Get Revision... (enter CL number, label, or YYYY/MM/DD)
- History right-click -> Sync workspace to #N
- Backend p4 sync scope@rev with live transfer progress

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 11:43:56 +03:00
e2ad438ed4 Typemap / exclusive-lock (+l) manager for Unreal binaries
- Tools -> Exclusive Locks (typemap): view/edit server typemap, one-click add
  recommended binary+l rules for UE asset types (.uasset/.umap/.fbx/textures/etc)
- Per-file 'Set exclusive-lock type (+l)' in the Changes context menu
- Backend p4 typemap -o/-i and reopen -t

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 11:41:24 +03:00
b40358e0f5 Fix giant Select-All checkbox (class collision) + disable UI text selection
- Select-All checkbox rendered huge when not-all-selected because its empty-state
  modifier class 'empty' collided with the .empty full-panel placeholder rule
  (flex:1; padding:30px). Renamed the modifier to 'off'.
- Disable text selection on UI chrome (native-app feel); keep it enabled for
  inputs, the code editor/viewer, terminal output, log, blame/diff, AI summary.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 11:01:55 +03:00
29303cbf43 Menu icons, File Locks viewer, remove dock drag highlight
- Add icons to every dropdown menu item (File/Connection/Actions/Window/Tools/Help)
- New File Locks viewer (Window -> File Locks): all open/locked files across the
  depot with who holds them (user + workspace), filter, locked-only toggle,
  unlock your own locks, refresh
- Remove the purple highlight on the bottom-dock resize handle (cursor only)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 09:59:42 +03:00
c175d734d7 0.2.4 — fix code-editor white screen, rename package to "Exbyte Depot"
- Fix critical crash: opening the built-in code editor blanked the whole app
  (white screen). The editing early-return sat above the syntax-highlight
  useMemo, so entering edit mode changed the hook count and React tore down the
  tree (Rules of Hooks violation). Moved all hooks above the early returns.
- Diff-vs-previous button is now shown only for text/code files, not binaries
  (p4 diff2 on a .uasset only prints "files differ", which is useless).
- Package renamed to "Exbyte Depot" (productName + mainBinaryName); identifier
  unchanged so existing installs upgrade in place.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 09:43:33 +03:00
0da836b0ac 0.2.3 — built-in code editor, .uasset previews, new icon
- Built-in code editor: edit working-copy source files in place (Edit → textarea
  → Save) before committing, no external IDE needed. Backend write_depot opens
  the file for edit, clears read-only, writes UTF-8; confined to the client root.
- .uasset previews: pull the embedded Unreal thumbnail (PNG) server-side for both
  working copy and history; on-demand real 3D via a headless Unreal FBX export
  (commandlet mode, no editor window), temp file deleted right after — nothing
  cached on disk. Plugin content paths mapped to their mount point.
- New app icon (brand mark on a theme tile) + theme-aware in-app logo.
- Connection pill / Settings show the actual connected server (P4PORT), not the
  server's internal self-reported address.
- Fix: the "select all" checkbox no longer renders as a giant empty square when
  not everything is selected (size pinned).
- IDE picker chooses which installed editor opens code; AI replies in UI language.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 22:55:04 +03:00
6c16d4a4f4 Hardening + collaboration features from full app review
Security and robustness (backend):
- Strict CSP (was null); confine read_file/launch_file/ue_log to the client root
- p4 trust without -f: surface fingerprint CHANGES instead of auto-accepting (MITM)
- stdin=null by default + non-interactive P4EDITOR so terminal commands never hang
- 64 MB read caps on read_depot/read_file/print_depot (no OOM on huge .uasset/.pak)
- Recover from mutex poisoning instead of crashing every later p4 call
- Decode p4 output as UTF-8 then cp1251 (Cyrillic paths no longer corrupt)
- p4_changes never deletes changelists that hold shelved files
- Validate changelist number in p4_undo_change

Bug fixes (frontend):
- Focus-refresh preserves the checkbox selection (never re-checks deselected files)
- Staleness guards on refresh/describe/AI-summary/user-groups (no cross-selection races)
- Real F5/Ctrl+R/G/S/B handlers; match e.code so Ctrl+backquote works on RU layout
- Confirm dialog no longer fires on a stray global Enter
- Panel/dock/history drags end on window blur (no stuck resize)
- Commit draft clears on Submit only if unchanged since committing
- Summary/IDE buttons gated to real source files (not .pak/.dll/binaries)

New features:
- Exclusive lock/unlock plus held-by/locked-by indicators (p4 opened -a)
- Shelve/unshelve/delete-shelf on pending changelists
- Resolve UI (auto-merge / accept yours / accept theirs, per file or all) + banner
- Diff a history revision against the previous one; blame/annotate view
- Depot file search (Tools -> Search depot)
- Move opened files between pending changelists
- New-submit toasts from teammates + offline banner with auto-reconnect

Also: bake OpenRouter key at build time (git-ignored), drop AI settings UI,
IDE picker (detect installed editors), AI replies in the UI language,
persistent code summaries, metadata/CSP polish.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 21:26:57 +03:00
68 changed files with 4283 additions and 263 deletions

4
.gitignore vendored
View File

@ -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

View File

@ -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

View File

@ -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>

14
package-lock.json generated
View File

@ -1,15 +1,16 @@
{
"name": "exbyte-depot",
"version": "0.2.0",
"version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "exbyte-depot",
"version": "0.2.0",
"version": "0.3.0",
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",
@ -1456,6 +1457,15 @@
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tauri-apps/plugin-notification": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
"integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-opener": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",

View File

@ -1,7 +1,7 @@
{
"name": "exbyte-depot",
"private": true,
"version": "0.2.2",
"version": "0.3.1",
"type": "module",
"scripts": {
"dev": "vite",
@ -12,6 +12,7 @@
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.1",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-opener": "^2",
"@tauri-apps/plugin-process": "^2.3.1",
"@tauri-apps/plugin-updater": "^2.10.1",

165
src-tauri/Cargo.lock generated
View File

@ -343,6 +343,12 @@ version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]]
name = "byteorder-lite"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
[[package]]
name = "bytes"
version = "1.12.0"
@ -874,6 +880,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,13 +966,15 @@ dependencies = [
[[package]]
name = "exbyte-depot"
version = "0.2.2"
version = "0.3.0"
dependencies = [
"encoding_rs",
"serde",
"serde_json",
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-notification",
"tauri-plugin-opener",
"tauri-plugin-process",
"tauri-plugin-updater",
@ -1723,6 +1740,19 @@ dependencies = [
"icu_properties",
]
[[package]]
name = "image"
version = "0.25.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"moxcms",
"num-traits",
"png 0.18.1",
]
[[package]]
name = "indexmap"
version = "1.9.3"
@ -2012,6 +2042,20 @@ version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]]
name = "mac-notification-sys"
version = "0.6.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd604973958ddcc11b561193c0fb96ba146506ef2f231ef2e7c35fd2cbc9beca"
dependencies = [
"cc",
"log",
"objc2",
"objc2-foundation",
"time",
"uuid",
]
[[package]]
name = "markup5ever"
version = "0.38.0"
@ -2071,6 +2115,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "moxcms"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
dependencies = [
"num-traits",
"pxfm",
]
[[package]]
name = "muda"
version = "0.19.3"
@ -2122,6 +2176,20 @@ version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "notify-rust"
version = "4.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5b4c1b4f2aa9f25f63a7a49d3dd0ed567b3670da15330a66b29434be899b891"
dependencies = [
"futures-lite",
"log",
"mac-notification-sys",
"serde",
"tauri-winrt-notification",
"zbus",
]
[[package]]
name = "num-conv"
version = "0.2.2"
@ -2624,6 +2692,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
dependencies = [
"zerocopy",
]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
@ -2692,6 +2769,12 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "pxfm"
version = "0.1.30"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea"
[[package]]
name = "quick-xml"
version = "0.41.0"
@ -2722,6 +2805,35 @@ version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rand"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha",
"rand_core",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core",
]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "raw-window-handle"
version = "0.6.2"
@ -3583,6 +3695,7 @@ dependencies = [
"gtk",
"heck 0.5.0",
"http",
"image",
"jni 0.21.1",
"libc",
"log",
@ -3737,6 +3850,25 @@ dependencies = [
"url",
]
[[package]]
name = "tauri-plugin-notification"
version = "2.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc"
dependencies = [
"log",
"notify-rust",
"rand",
"serde",
"serde_json",
"serde_repr",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
"time",
"url",
]
[[package]]
name = "tauri-plugin-opener"
version = "2.5.4"
@ -3902,6 +4034,17 @@ dependencies = [
"toml 1.1.2+spec-1.1.0",
]
[[package]]
name = "tauri-winrt-notification"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed071c670382e85fc2f48ae706492d8c338f4f89bf72520d32f8abfe880aade"
dependencies = [
"thiserror 2.0.18",
"windows",
"windows-version",
]
[[package]]
name = "tempfile"
version = "3.27.0"
@ -5289,6 +5432,26 @@ dependencies = [
"zvariant",
]
[[package]]
name = "zerocopy"
version = "0.8.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75726053136156d419e285b9b7eddaaea9e3fea6ce32eed44a89901f0bd98de1"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.53"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4714fd92cf900833d49538023a9b3915155210801d1c1169eba513b2addefd71"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.118",
]
[[package]]
name = "zerofrom"
version = "0.1.8"

View File

@ -1,8 +1,8 @@
[package]
name = "exbyte-depot"
version = "0.2.2"
description = "A Tauri App"
authors = ["you"]
version = "0.3.1"
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
@ -18,11 +18,14 @@ crate-type = ["staticlib", "cdylib", "rlib"]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-opener = "2"
tauri-plugin-notification = "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]

View File

@ -11,8 +11,13 @@
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-close",
"core:window:allow-show",
"core:window:allow-hide",
"core:window:allow-set-focus",
"core:window:allow-unminimize",
"dialog:default",
"updater:default",
"process:default"
"process:default",
"notification:default"
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 820 B

After

Width:  |  Height:  |  Size: 970 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 12 KiB

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,8 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "exbyte-depot",
"version": "0.2.2",
"productName": "Exbyte Depot",
"mainBinaryName": "Exbyte Depot",
"version": "0.3.1",
"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; media-src 'self' blob:; 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; media-src 'self' blob:; 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": {

View File

@ -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,14 +24,22 @@ 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;
}
/* 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 */
@ -48,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}
@ -68,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}
@ -166,7 +179,7 @@ body.resizing{cursor:col-resize!important;user-select:none}
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)}
@ -363,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;
@ -445,6 +489,7 @@ body.resizing{cursor:col-resize!important;user-select:none}
.summary::placeholder{color:var(--faint)}
.desc{background:var(--panel);border:1px solid var(--border);border-radius:9px;padding:9px 11px;font-size:12.5px;color:var(--txt);min-height:46px;resize:none;outline:none;font-family:var(--font)}
.desc::placeholder{color:var(--faint)}
.summary:focus,.desc:focus{border-color:var(--accent);box-shadow:0 0 0 3px rgba(124,110,246,.14)}
.submit{border:none;border-radius:10px;padding:11px;font-size:13.5px;font-weight:600;color:#fff;cursor:pointer;
background:linear-gradient(135deg,var(--accent-2),var(--accent-deep));
box-shadow:0 8px 24px -6px rgba(124,110,246,.6),0 0 0 1px rgba(255,255,255,.08) inset;
@ -465,6 +510,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;
@ -475,6 +528,15 @@ body.resizing{cursor:col-resize!important;user-select:none}
.stage .lbl2{position:absolute;top:14px;left:14px;font-size:11px;color:var(--muted);background:var(--chip);
border:1px solid var(--border);border-radius:20px;padding:5px 11px;display:flex;align-items:center;gap:7px}
.stage .hint{position:absolute;bottom:14px;right:14px;font-size:11px;color:var(--faint)}
/* audio / video preview */
.mediaplayer{width:min(460px,86%)}
.mediaplayer.vid{width:auto;max-width:92%;max-height:88%;border-radius:12px;box-shadow:0 24px 60px -24px #000;background:#000}
.audiobox{display:flex;flex-direction:column;align-items:center;gap:16px;width:min(460px,86%)}
.audio-art{width:104px;height:104px;border-radius:22px;display:grid;place-items:center;color:#fff;
background:linear-gradient(140deg,var(--accent),var(--accent-deep));box-shadow:0 20px 46px -18px var(--accent)}
.audio-art svg{width:48px;height:48px}
.audio-name{font-size:14px;font-weight:600;color:var(--txt);text-align:center;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;padding:0 10px}
.audiobox audio{width:100%}
.assetmeta{border-top:1px solid var(--border);padding:15px 20px;display:grid;grid-template-columns:repeat(4,1fr);gap:13px 22px;background:var(--sunk)}
.mi{display:flex;flex-direction:column;gap:3px}
.mi .k{font-size:10.5px;color:var(--faint);text-transform:uppercase;letter-spacing:.6px}
@ -508,7 +570,7 @@ body.resizing{cursor:col-resize!important;user-select:none}
display:flex;align-items:center;justify-content:center;gap:9px}
.connect:disabled{opacity:.6;cursor:not-allowed}.connect:not(:disabled):hover{filter:brightness(1.08)}.connect svg{width:16px;height:16px}
.spin{animation:spin 1s linear infinite}
.ldr{width:24px;height:24px;border:2.5px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite}
.ldr{display:inline-block;box-sizing:border-box;vertical-align:middle;width:24px;height:24px;border:2.5px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin .8s linear infinite;flex:0 0 auto}
/* diff view */
.diff{flex:1;overflow:auto;min-height:0;font-family:var(--mono);font-size:12.5px;line-height:1.85;padding:6px 0}
@ -537,6 +599,33 @@ 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}
/* spec editors (client / job / .p4ignore): a clean editor surface, not a flat grey box */
.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(--elevated);padding:12px 16px 14px 18px;white-space:pre;overflow:auto;
box-shadow:inset 3px 0 0 var(--border-soft)}
.code-edit:focus{box-shadow:inset 3px 0 0 var(--accent)}
/* rich code editor: transparent textarea over a highlighted underlay + gutter */
.code.editing{overflow:hidden}
.code.editing .code-gutter{overflow:hidden}
.ce-wrap{position:relative;flex:1;min-width:0;background:transparent}
.ce-under,.ce-ta{margin:0;padding:10px 16px;border:0;font-family:var(--mono);font-size:12.5px;line-height:1.7;tab-size:4;white-space:pre;word-wrap:normal;letter-spacing:normal}
.ce-under{position:absolute;inset:0;overflow:hidden;pointer-events:none;background:none;color:var(--txt)}
.ce-under code{font-family:inherit;background:none;padding:0;white-space:pre}
.ce-ta{position:absolute;inset:0;width:100%;height:100%;resize:none;outline:none;overflow:auto;background:transparent;color:transparent;caret-color:var(--accent-2)}
.ce-ta::selection{background:rgba(124,110,246,.28);color:transparent}
.ce-ta::-moz-selection{background:rgba(124,110,246,.28);color:transparent}
/* 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}
@ -567,18 +656,37 @@ body.resizing{cursor:col-resize!important;user-select:none}
/* 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}
/* explicit width via a state-driven CSS variable */
.histsplit>.histlist{flex:none;width:var(--hist-w,340px);min-width:0;border-right:1px solid var(--border)}
.histsplit.solo>.histlist{flex:1 1 auto;width:auto;border-right:none} /* nothing open → list fills the panel */
/* 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)}
/* ---- right-side view panel with tabs (File Viewer / File Tree) ---- */
.viewpanel{display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden}
.vtabs{display:flex;align-items:stretch;gap:2px;padding:6px 8px 0;border-bottom:1px solid var(--border);background:var(--sunk);flex:0 0 auto;overflow-x:auto}
.vtab{display:flex;align-items:center;gap:7px;padding:7px 12px;border-radius:9px 9px 0 0;font-size:12.5px;font-weight:600;color:var(--muted);cursor:pointer;white-space:nowrap;border:1px solid transparent;border-bottom:none;position:relative;top:1px}
.vtab:hover{color:var(--txt);background:var(--hover)}
.vtab.on{color:var(--txt);background:var(--bg);border-color:var(--border)}
.vtab-ic{display:grid;place-items:center;color:var(--accent-2)}
.vtab-ic svg{width:15px;height:15px}
.vtab-x{margin-left:4px;width:17px;height:17px;display:grid;place-items:center;border-radius:5px;font-size:15px;line-height:1;color:var(--faint)}
.vtab-x:hover{background:var(--del);color:#fff}
.viewbody{flex:1;display:flex;min-height:0;min-width:0;position:relative}
.vpane{flex:1;min-width:0;min-height:0}
.vpane>.right,.vpane>.histsplit{flex:1 1 auto;min-width:0;min-height:0}
.vpane.tree{flex-direction:column}
.ft-bar{display:flex;align-items:center;gap:10px;padding:9px 14px;border-bottom:1px solid var(--border-soft);flex:0 0 auto}
.ft-total{margin-left:auto;font-size:12px;color:var(--muted);white-space:nowrap;font-variant-numeric:tabular-nums}
.ft-total b{color:var(--accent-2)}
.vpane.tree .exp-tree{flex:1;min-height: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}
.hmeta{font-size:11px;color:var(--faint)}
.hsize{margin-left:7px;padding:1px 6px;border-radius:6px;background:var(--chip);border:1px solid var(--border-soft);color:var(--muted);font-weight:600;font-variant-numeric:tabular-nums;white-space:nowrap}
/* 3d viewer toolbar */
.v3d-tools{position:absolute;top:12px;right:12px;display:flex;gap:6px;z-index:2}
@ -673,6 +781,103 @@ body.resizing{cursor:col-resize!important;user-select:none}
.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)}
/* explicit "Work Offline" mode — violet, calmer than the error-red disconnect bar */
.netbar.offmode{background:rgba(124,110,246,.13);color:var(--accent-2);border-bottom:1px solid rgba(124,110,246,.3)}
/* "you're behind the server" — informational blue, clickable to Get Latest */
.netbar.behind{background:rgba(90,160,255,.14);color:#4f8fe0;border-bottom:1px solid rgba(90,160,255,.32);cursor:pointer}
.netbar.behind:hover{background:rgba(90,160,255,.2)}
.behind-names{font-weight:500;opacity:.8;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
.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)}
.netbar-btn+.netbar-btn{margin-left:0}
.netbar-btn:hover{background:currentColor}
.netbar-btn:hover{color:var(--bg)}
/* reconcile offline-work dialog */
.rec-summary{display:flex;align-items:center;gap:6px;padding:8px 16px;font-size:12px;color:var(--muted);border-bottom:1px solid var(--border-soft)}
.rec-all{margin-left:auto;color:var(--accent-2);font-weight:700;cursor:pointer}
.rec-all:hover{text-decoration:underline}
.rec-row{cursor:pointer}
.rec-row .chk{flex:0 0 16px}
/* "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-row.on{background:rgba(124,110,246,.12);box-shadow:inset 0 0 0 1px rgba(124,110,246,.25)}
.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}
.tm-hint{padding:10px 16px;font-size:11.5px;line-height:1.5;color:var(--faint);border-bottom:1px solid var(--border-soft)}
/* file-history timeline */
.flog-body{flex:1;overflow:auto;padding:6px 14px 14px}
.flog-row{display:flex;gap:12px;align-items:flex-start;padding:8px 6px}
.flog-row:hover{background:var(--hover);border-radius:8px}
.flog-rail{flex:0 0 14px;display:flex;flex-direction:column;align-items:center;align-self:stretch;position:relative}
.flog-dot{width:11px;height:11px;border-radius:50%;flex:0 0 auto;margin-top:4px;background:var(--accent-2);box-shadow:0 0 0 3px rgba(124,110,246,.15)}
.flog-dot.st-a{background:var(--add)}.flog-dot.st-d{background:var(--del)}.flog-dot.st-e{background:var(--edit)}
.flog-line{flex:1;width:2px;background:var(--border);margin-top:2px}
.flog-main{flex:1;min-width:0}
.flog-head{display:flex;align-items:center;gap:8px;font-size:12px;flex-wrap:wrap}
.flog-head .rev{font-weight:700;color:var(--txt);font-variant-numeric:tabular-nums}
.flog-cl{color:var(--accent-2);font-variant-numeric:tabular-nums}
.flog-user{color:var(--muted)}.flog-time{color:var(--faint);margin-left:auto;font-variant-numeric:tabular-nums}
.flog-desc{font-size:12px;color:var(--muted);margin-top:3px;line-height:1.5;white-space:pre-wrap;word-break:break-word}
/* 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)}
@ -687,8 +892,37 @@ body.resizing{cursor:col-resize!important;user-select:none}
/* ---------------- People & Roles ---------------- */
.picker.wide{width:720px;max-width:94%;height:560px;max-height:82vh;display:flex;flex-direction:column}
.picker.wide.tall{height:660px}
.ph-sub{margin-left:8px;font-size:11.5px;color:var(--faint);font-variant-numeric:tabular-nums}
.picker.wide .ph-sub{margin-right:auto}
/* ---- Depot & Workspace file/size explorer ---- */
.exp-tabs{display:flex;gap:4px;margin-left:14px;background:var(--sunk);padding:3px;border-radius:9px}
.exp-tab{border:none;background:none;color:var(--muted);font-size:12px;font-weight:600;padding:5px 14px;border-radius:7px;cursor:pointer;font-family:var(--font)}
.exp-tab:hover{color:var(--txt)}
.exp-tab.on{background:var(--accent);color:#fff}
.exp-total{display:flex;align-items:center;padding:10px 18px;border-bottom:1px solid var(--border-soft)}
.exp-total .n{font-size:13px;font-weight:700;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.exp-total .sz{margin-left:auto;font-size:13px;font-weight:700;color:var(--accent-2);font-variant-numeric:tabular-nums;white-space:nowrap;padding-left:12px}
.exp-tree{flex:1;overflow-y:auto;padding:6px 8px 12px}
.exp-node{display:flex;flex-direction:column}
.exp-row{display:flex;align-items:center;gap:7px;padding:5px 10px;border-radius:8px;cursor:default;transition:background .1s}
.exp-row.dir{cursor:pointer}
.exp-row:hover{background:var(--hover)}
.exp-row:hover .exp-open{opacity:1}
.exp-caret{width:14px;flex:0 0 14px;display:grid;place-items:center;color:var(--faint)}
.chevi{display:grid;place-items:center;transition:transform .12s}
.chevi svg{width:13px;height:13px}
.chevi.open{transform:rotate(90deg)}
.exp-ic{width:17px;height:17px;flex:0 0 auto;display:grid;place-items:center;color:var(--accent-2)}
.exp-ic svg{width:16px;height:16px}
.exp-name{font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:60px}
.exp-count{font-size:10.5px;color:var(--faint);white-space:nowrap;flex:0 0 auto}
.exp-bar{margin-left:auto;flex:0 0 110px;height:6px;border-radius:4px;background:var(--sunk);overflow:hidden}
.exp-bar > span{display:block;height:100%;background:linear-gradient(90deg,var(--accent),var(--accent-2));border-radius:4px}
.exp-size{flex:0 0 68px;text-align:right;font-size:12px;font-weight:600;color:var(--muted);font-variant-numeric:tabular-nums}
.exp-open{flex:0 0 auto;width:22px;height:22px;display:grid;place-items:center;color:var(--faint);cursor:pointer;opacity:0;border-radius:6px;transition:opacity .1s,color .1s}
.exp-open svg{width:14px;height:14px}
.exp-open:hover{color:var(--accent-2);background:var(--panel)}
.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)}

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import hljs from "highlight.js/lib/core";
import cpp from "highlight.js/lib/languages/cpp";
import csharp from "highlight.js/lib/languages/csharp";
@ -45,8 +45,9 @@ 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);
@ -55,10 +56,14 @@ export default function CodeView({ file }: { file: OpenedFile }) {
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);
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(""));
@ -72,14 +77,83 @@ 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, spec]);
}, [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 "";
try { return lang ? hljs.highlight(code, { language: lang, ignoreIllegals: true }).value : esc(code); }
catch { return esc(code); }
}, [code, lang]);
// live-highlighted draft for the editor underlay (same highlighter as the preview)
const draftHi = useMemo(() => {
try { return lang ? hljs.highlight(draft, { language: lang, ignoreIllegals: true }).value : esc(draft); }
catch { return esc(draft); }
}, [draft, lang]);
const preRef = useRef<HTMLPreElement>(null);
const gutRef = useRef<HTMLDivElement>(null);
const draftRows = draft.split("\n").length;
// editing mode — a real code editor: transparent textarea over a syntax-
// highlighted underlay, with a line-number gutter (looks like the preview).
if (editing) {
const syncScroll = (e: { currentTarget: HTMLTextAreaElement }) => {
const ta = e.currentTarget;
if (preRef.current) { preRef.current.scrollTop = ta.scrollTop; preRef.current.scrollLeft = ta.scrollLeft; }
if (gutRef.current) gutRef.current.scrollTop = ta.scrollTop;
};
return (
<div className="right-body">
<div className="codebar">
<span className="cb-badge editing">{t("Editing")}</span>
<span className="cb-name">{name}</span>
{lang ? <span className="cb-lines">{LANG_LABEL[lang]}</span> : null}
<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>
<div className="code editing">
<div className="code-gutter" ref={gutRef}>{Array.from({ length: draftRows }, (_, i) => <span key={i}>{i + 1}</span>)}</div>
<div className="ce-wrap">
<pre className="ce-under" aria-hidden="true" ref={preRef}><code className="hljs" dangerouslySetInnerHTML={{ __html: draftHi + (draft.endsWith("\n") ? " " : "") }} /></pre>
<textarea className="ce-ta" value={draft} spellCheck={false} autoFocus onScroll={syncScroll} 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>
</div>
</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>;
@ -90,7 +164,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 = "";
@ -113,6 +189,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>

View File

@ -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, spec }: { depot: string; name: string; spec?: 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, spec }: { depot: string; name
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, spec }: { depot: string; name
(async () => {
try {
const buf = spec ? await p4.printDepot(spec) : 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, spec }: { depot: string; name
api.current = null;
};
// eslint-disable-next-line
}, [depot, name, spec]);
}, [depot, name, spec, bytes, forceExt]);
const fmt = (n: number) => n.toLocaleString("ru");
return (

View File

@ -1,37 +1,33 @@
// OpenRouter-powered commit summary + small avatar helpers.
import { p4, OpenedFile, statusOf } from "./p4";
import { LANG } from "./i18n";
const MODEL_KEY = "exd-ai-model";
const KEY_KEY = "exd-ai-key";
// cheap + reliable default; change to a "…:free" model in Settings to pay nothing
// 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";
export function getModel(): string { try { return localStorage.getItem(MODEL_KEY) || DEFAULT_MODEL; } catch { return DEFAULT_MODEL; } }
export function setModel(m: string) { try { localStorage.setItem(MODEL_KEY, m.trim() || DEFAULT_MODEL); } catch {} }
export function getKeyLocal(): string { try { return localStorage.getItem(KEY_KEY) || ""; } catch { return ""; } }
export function setKeyLocal(k: string) { try { localStorage.setItem(KEY_KEY, k.trim()); } catch {} }
// key resolution: localStorage first, else the app-config file (git-ignored)
// the key is baked into the binary at build time (src-tauri/openrouter.key)
export async function resolveKey(): Promise<string> {
const local = getKeyLocal();
if (local) return local;
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 — set it in Settings");
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: getModel(),
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." },
{ 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}` },
],
}),
@ -49,6 +45,53 @@ export async function aiSummary(files: OpenedFile[]): Promise<{ summary: string;
return { summary, description };
}
// briefly explain what a source file does (24 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 (24 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();

View File

@ -75,6 +75,18 @@ const D: Record<string, Tr> = {
"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 dabord 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 linstant. 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 dabord des fichiers.", es: "Selecciona primero algunos archivos." },
@ -190,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 lenverra au serveur. Lhistorique 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}" },
@ -308,6 +326,255 @@ 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." },
"Exclusive Locks (typemap)…": { ru: "Эксклюзивные локи (typemap)…", de: "Exklusive Sperren (typemap)…", fr: "Verrous exclusifs (typemap)…", es: "Bloqueos exclusivos (typemap)…" },
"Exclusive Locks (typemap)": { ru: "Эксклюзивные локи (typemap)", de: "Exklusive Sperren (typemap)", fr: "Verrous exclusifs (typemap)", es: "Bloqueos exclusivos (typemap)" },
"Set exclusive-lock type (+l)": { ru: "Задать тип с эксклюзивным локом (+l)", de: "Typ mit exklusiver Sperre setzen (+l)", fr: "Définir le type verrou exclusif (+l)", es: "Establecer tipo de bloqueo exclusivo (+l)" },
"{n} file(s) set to exclusive-lock type (+l).": { ru: "Файлов с эксклюзивным локом (+l): {n}.", de: "{n} Datei(en) auf exklusive Sperre (+l) gesetzt.", fr: "{n} fichier(s) en verrou exclusif (+l).", es: "{n} archivo(s) en tipo de bloqueo exclusivo (+l)." },
"{n} rules": { ru: "правил: {n}", de: "{n} Regeln", fr: "{n} règles", es: "{n} reglas" },
"Files matching a “binary+l” rule are exclusive-checkout: only one person can edit them at a time. Recommended for Unreal binary assets. Changing the typemap needs admin rights.": { ru: "Файлы под правилом «binary+l» — эксклюзивный чекаут: их может править только один человек за раз. Рекомендуется для бинарных ассетов Unreal. Смена typemap требует прав администратора.", de: "Dateien mit einer „binary+l“-Regel sind Exklusiv-Checkout: nur eine Person kann sie gleichzeitig bearbeiten. Empfohlen für binäre Unreal-Assets. Das Ändern der Typemap erfordert Adminrechte.", fr: "Les fichiers correspondant à une règle « binary+l » sont en extraction exclusive : une seule personne peut les modifier à la fois. Recommandé pour les assets binaires Unreal. Modifier la typemap nécessite des droits admin.", es: "Los archivos que coinciden con una regla «binary+l» son de extracción exclusiva: solo una persona puede editarlos a la vez. Recomendado para assets binarios de Unreal. Cambiar la typemap requiere permisos de administrador." },
"Add recommended Unreal rules": { ru: "Добавить рекомендованные правила Unreal", de: "Empfohlene Unreal-Regeln hinzufügen", fr: "Ajouter les règles Unreal recommandées", es: "Añadir reglas recomendadas de Unreal" },
"Add rule, e.g. binary+l //....uasset": { ru: "Добавить правило, напр. binary+l //....uasset", de: "Regel hinzufügen, z. B. binary+l //....uasset", fr: "Ajouter une règle, ex. binary+l //....uasset", es: "Añadir regla, p. ej. binary+l //....uasset" },
"No typemap rules yet.": { ru: "Правил typemap пока нет.", de: "Noch keine Typemap-Regeln.", fr: "Aucune règle de typemap pour l'instant.", es: "Aún no hay reglas de typemap." },
"All recommended rules are already present.": { ru: "Все рекомендованные правила уже есть.", de: "Alle empfohlenen Regeln sind bereits vorhanden.", fr: "Toutes les règles recommandées sont déjà présentes.", es: "Todas las reglas recomendadas ya están presentes." },
"Typemap saved.": { ru: "Typemap сохранён.", de: "Typemap gespeichert.", fr: "Typemap enregistrée.", es: "Typemap guardada." },
"Save typemap": { ru: "Сохранить typemap", de: "Typemap speichern", fr: "Enregistrer la typemap", es: "Guardar typemap" },
"Remove": { ru: "Убрать", de: "Entfernen", fr: "Retirer", es: "Quitar" },
"Get Revision…": { ru: "Взять ревизию…", de: "Revision holen…", fr: "Obtenir une révision…", es: "Obtener revisión…" },
"Get Revision": { ru: "Взять ревизию", de: "Revision holen", fr: "Obtenir une révision", es: "Obtener revisión" },
"Changelist number, label name, or date (YYYY/MM/DD)": { ru: "Номер changelist, имя метки или дата (ГГГГ/ММ/ДД)", de: "Changelist-Nummer, Label-Name oder Datum (JJJJ/MM/TT)", fr: "Numéro de changelist, nom de label ou date (AAAA/MM/JJ)", es: "Número de changelist, nombre de etiqueta o fecha (AAAA/MM/DD)" },
"Sync workspace to {rev}?": { ru: "Синкнуть воркспейс на {rev}?", de: "Arbeitsbereich auf {rev} synchronisieren?", fr: "Synchroniser l'espace de travail sur {rev} ?", es: "¿Sincronizar el espacio de trabajo a {rev}?" },
"Your synced files change to match {rev}. Un-submitted (opened) work is not touched. You can Get Latest to return to head.": { ru: "Синкнутые файлы изменятся под {rev}. Незасабмиченные (открытые) правки не тронутся. Get Latest вернёт на голову.", de: "Deine synchronisierten Dateien werden an {rev} angepasst. Nicht übermittelte (offene) Arbeit bleibt unberührt. Mit „Get Latest“ kehrst du zum Head zurück.", fr: "Vos fichiers synchronisés passent à {rev}. Le travail non soumis (ouvert) n'est pas touché. « Get Latest » vous ramène à la tête.", es: "Tus archivos sincronizados cambian a {rev}. El trabajo no enviado (abierto) no se toca. Con «Get Latest» vuelves a la cabeza." },
"Synced to {rev}: {msg}": { ru: "Синкнуто на {rev}: {msg}", de: "Auf {rev} synchronisiert: {msg}", fr: "Synchronisé sur {rev} : {msg}", es: "Sincronizado a {rev}: {msg}" },
"Sync workspace to #{n}": { ru: "Синкнуть воркспейс на #{n}", de: "Arbeitsbereich auf #{n} synchronisieren", fr: "Synchroniser l'espace de travail sur #{n}", es: "Sincronizar el espacio de trabajo a #{n}" },
"New workspace…": { ru: "Новый воркспейс…", de: "Neuer Arbeitsbereich…", fr: "Nouvel espace de travail…", es: "Nuevo espacio de trabajo…" },
"New workspace": { ru: "Новый воркспейс", de: "Neuer Arbeitsbereich", fr: "Nouvel espace de travail", es: "Nuevo espacio de trabajo" },
"Edit current workspace…": { ru: "Редактировать текущий воркспейс…", de: "Aktuellen Arbeitsbereich bearbeiten…", fr: "Modifier l'espace de travail actuel…", es: "Editar el espacio de trabajo actual…" },
"Edit workspace": { ru: "Редактирование воркспейса", de: "Arbeitsbereich bearbeiten", fr: "Modifier l'espace de travail", es: "Editar espacio de trabajo" },
"Workspace (client) name": { ru: "Имя воркспейса (client)", de: "Arbeitsbereich- (Client-)Name", fr: "Nom de l'espace de travail (client)", es: "Nombre del espacio de trabajo (client)" },
"Edit the workspace spec. Set Root to a local folder and adjust the View mapping (depot → workspace). Lines starting with # are comments.": { ru: "Отредактируй спеку воркспейса. Укажи Root — локальную папку, и настрой View (депо → воркспейс). Строки с # — комментарии.", de: "Bearbeite die Arbeitsbereich-Spezifikation. Setze Root auf einen lokalen Ordner und passe das View-Mapping (Depot → Arbeitsbereich) an. Zeilen mit # sind Kommentare.", fr: "Modifiez la spec de l'espace de travail. Définissez Root sur un dossier local et ajustez le mappage View (dépôt → espace de travail). Les lignes commençant par # sont des commentaires.", es: "Edita la especificación del espacio de trabajo. Establece Root en una carpeta local y ajusta el mapeo View (depósito → espacio de trabajo). Las líneas que empiezan con # son comentarios." },
"Workspace “{c}” created.": { ru: "Воркспейс «{c}» создан.", de: "Arbeitsbereich „{c}“ erstellt.", fr: "Espace de travail « {c} » créé.", es: "Espacio de trabajo «{c}» creado." },
"Workspace “{c}” updated.": { ru: "Воркспейс «{c}» обновлён.", de: "Arbeitsbereich „{c}“ aktualisiert.", fr: "Espace de travail « {c} » mis à jour.", es: "Espacio de trabajo «{c}» actualizado." },
"Create & switch": { ru: "Создать и переключиться", de: "Erstellen & wechseln", fr: "Créer et basculer", es: "Crear y cambiar" },
"Labels…": { ru: "Метки…", de: "Labels…", fr: "Labels…", es: "Etiquetas…" },
"Labels": { ru: "Метки", de: "Labels", fr: "Labels", es: "Etiquetas" },
"{n} labels": { ru: "меток: {n}", de: "{n} Labels", fr: "{n} labels", es: "{n} etiquetas" },
"Filter labels…": { ru: "Фильтр меток…", de: "Labels filtern…", fr: "Filtrer les labels…", es: "Filtrar etiquetas…" },
"New label name": { ru: "Имя новой метки", de: "Neuer Label-Name", fr: "Nom du nouveau label", es: "Nombre de nueva etiqueta" },
"Tag current": { ru: "Отметить текущее", de: "Aktuelles taggen", fr: "Étiqueter l'actuel", es: "Etiquetar actual" },
"Label “{c}” tagged at current head.": { ru: "Метка «{c}» проставлена на текущую голову.", de: "Label „{c}“ am aktuellen Head getaggt.", fr: "Label « {c} » posé sur la tête actuelle.", es: "Etiqueta «{c}» marcada en la cabeza actual." },
"No labels.": { ru: "Меток нет.", de: "Keine Labels.", fr: "Aucun label.", es: "Sin etiquetas." },
"Sync to": { ru: "Синкнуть на", de: "Sync auf", fr: "Synchroniser sur", es: "Sincronizar a" },
"Integrate / Merge / Copy…": { ru: "Интеграция / Merge / Copy…", de: "Integrate / Merge / Copy…", fr: "Intégrer / Fusionner / Copier…", es: "Integrar / Fusionar / Copiar…" },
"Integrate / Merge / Copy": { ru: "Интеграция / Merge / Copy", de: "Integrate / Merge / Copy", fr: "Intégrer / Fusionner / Copier", es: "Integrar / Fusionar / Copiar" },
"Integrate": { ru: "Integrate", de: "Integrieren", fr: "Intégrer", es: "Integrar" },
"Merge changes from source into target — you resolve conflicts, then submit.": { ru: "Слить изменения из source в target — ты разрешаешь конфликты и сабмитишь.", de: "Änderungen von Quelle in Ziel zusammenführen — du löst Konflikte und übermittelst.", fr: "Fusionner les changements de la source vers la cible — vous résolvez les conflits puis soumettez.", es: "Fusiona los cambios del origen al destino — resuelves conflictos y envías." },
"Copy source over target verbatim (no merge). Target becomes identical to source.": { ru: "Скопировать source поверх target как есть (без merge). Target станет идентичен source.", de: "Quelle unverändert über Ziel kopieren (kein Merge). Ziel wird identisch zur Quelle.", fr: "Copier la source sur la cible telle quelle (sans fusion). La cible devient identique à la source.", es: "Copia el origen sobre el destino tal cual (sin fusión). El destino queda idéntico al origen." },
"Classic integrate — open target files for integration from source.": { ru: "Классический integrate — открыть файлы target для интеграции из source.", de: "Klassisches Integrate — Zieldateien zur Integration aus der Quelle öffnen.", fr: "Intégration classique — ouvrir les fichiers cibles pour intégration depuis la source.", es: "Integración clásica — abrir los archivos de destino para integrar desde el origen." },
"Target": { ru: "Цель", de: "Ziel", fr: "Cible", es: "Destino" },
"Source and target are required.": { ru: "Нужны источник и цель.", de: "Quelle und Ziel erforderlich.", fr: "Source et cible requises.", es: "Se requieren origen y destino." },
"{op} opened into a pending changelist. Resolve (if needed) and Submit. {out}": { ru: "{op}: файлы открыты в pending-changelist. Разреши (если нужно) и сабмить. {out}", de: "{op} in einen ausstehenden Changelist geöffnet. Auflösen (falls nötig) und übermitteln. {out}", fr: "{op} ouvert dans un changelist en attente. Résolvez (si besoin) et soumettez. {out}", es: "{op} abierto en un changelist pendiente. Resuelve (si hace falta) y envía. {out}" },
"Run {op}": { ru: "Запустить {op}", de: "{op} ausführen", fr: "Lancer {op}", es: "Ejecutar {op}" },
"Streams…": { ru: "Стримы…", de: "Streams…", fr: "Streams…", es: "Streams…" },
"Streams": { ru: "Стримы", de: "Streams", fr: "Streams", es: "Streams" },
"(not on a stream)": { ru: "(не на стриме)", de: "(nicht auf einem Stream)", fr: "(pas sur un stream)", es: "(sin stream)" },
"Current stream integration": { ru: "Интеграция текущего стрима", de: "Integration des aktuellen Streams", fr: "Intégration du stream actuel", es: "Integración del stream actual" },
"Merge down": { ru: "Merge down (из родителя)", de: "Merge down (vom Parent)", fr: "Merge down (depuis le parent)", es: "Merge down (desde el padre)" },
"Copy up": { ru: "Copy up (в родителя)", de: "Copy up (zum Parent)", fr: "Copy up (vers le parent)", es: "Copy up (al padre)" },
"Filter streams…": { ru: "Фильтр стримов…", de: "Streams filtern…", fr: "Filtrer les streams…", es: "Filtrar streams…" },
"No streams (this depot may be classic, not stream-based).": { ru: "Стримов нет (депо может быть классическим, не на стримах).", de: "Keine Streams (dieses Depot ist evtl. klassisch, nicht stream-basiert).", fr: "Aucun stream (ce dépôt est peut-être classique, pas basé sur des streams).", es: "Sin streams (este depósito puede ser clásico, no basado en streams)." },
"current": { ru: "текущий", de: "aktuell", fr: "actuel", es: "actual" },
"Switch": { ru: "Переключить", de: "Wechseln", fr: "Basculer", es: "Cambiar" },
"Switched to stream {s}": { ru: "Переключено на стрим {s}", de: "Zu Stream {s} gewechselt", fr: "Basculé sur le stream {s}", es: "Cambiado al stream {s}" },
"{d} opened into a pending changelist — resolve & submit. {out}": { ru: "{d}: файлы открыты в pending-changelist — разреши и сабмить. {out}", de: "{d} in einen ausstehenden Changelist geöffnet — auflösen & übermitteln. {out}", fr: "{d} ouvert dans un changelist en attente — résolvez et soumettez. {out}", es: "{d} abierto en un changelist pendiente — resuelve y envía. {out}" },
"Jobs…": { ru: "Задачи (jobs)…", de: "Jobs…", fr: "Jobs…", es: "Jobs…" },
"Jobs": { ru: "Задачи (jobs)", de: "Jobs", fr: "Jobs", es: "Jobs" },
"{n} jobs": { ru: "задач: {n}", de: "{n} Jobs", fr: "{n} jobs", es: "{n} jobs" },
"Filter jobs…": { ru: "Фильтр задач…", de: "Jobs filtern…", fr: "Filtrer les jobs…", es: "Filtrar jobs…" },
"New job": { ru: "Новая задача", de: "Neuer Job", fr: "Nouveau job", es: "Nuevo job" },
"Edit job": { ru: "Редактирование задачи", de: "Job bearbeiten", fr: "Modifier le job", es: "Editar job" },
"Edit the job spec. Fields depend on the server's jobspec. Lines starting with # are comments.": { ru: "Отредактируй спеку задачи. Поля зависят от jobspec сервера. Строки с # — комментарии.", de: "Bearbeite die Job-Spezifikation. Die Felder hängen vom Jobspec des Servers ab. Zeilen mit # sind Kommentare.", fr: "Modifiez la spec du job. Les champs dépendent du jobspec du serveur. Les lignes avec # sont des commentaires.", es: "Edita la especificación del job. Los campos dependen del jobspec del servidor. Las líneas con # son comentarios." },
"Job saved.": { ru: "Задача сохранена.", de: "Job gespeichert.", fr: "Job enregistré.", es: "Job guardado." },
"No jobs.": { ru: "Задач нет.", de: "Keine Jobs.", fr: "Aucun job.", es: "Sin jobs." },
"Back": { ru: "Назад", de: "Zurück", fr: "Retour", es: "Atrás" },
"Attach job…": { ru: "Привязать задачу…", de: "Job anhängen…", fr: "Associer un job…", es: "Adjuntar job…" },
"Attach job to #{n}": { ru: "Привязать задачу к #{n}", de: "Job an #{n} anhängen", fr: "Associer un job à #{n}", es: "Adjuntar job a #{n}" },
"Job ID": { ru: "ID задачи", de: "Job-ID", fr: "ID du job", es: "ID del job" },
"Job {j} linked to #{n}.": { ru: "Задача {j} привязана к #{n}.", de: "Job {j} mit #{n} verknüpft.", fr: "Job {j} associé à #{n}.", es: "Job {j} vinculado a #{n}." },
"File history": { ru: "История файла", de: "Dateiverlauf", fr: "Historique du fichier", es: "Historial del archivo" },
"No history.": { ru: "Истории нет.", de: "Kein Verlauf.", fr: "Aucun historique.", es: "Sin historial." },
"Diff ↔ prev": { ru: "Diff ↔ пред.", de: "Diff ↔ vorher", fr: "Diff ↔ préc.", es: "Diff ↔ ant." },
"Clean workspace…": { ru: "Очистить воркспейс…", de: "Arbeitsbereich bereinigen…", fr: "Nettoyer l'espace de travail…", es: "Limpiar espacio de trabajo…" },
"Clean workspace": { ru: "Очистка воркспейса", de: "Arbeitsbereich bereinigen", fr: "Nettoyer l'espace de travail", es: "Limpiar espacio de trabajo" },
"{n} changes": { ru: "изменений: {n}", de: "{n} Änderungen", fr: "{n} changements", es: "{n} cambios" },
"Clean makes the workspace exactly match the depot: extra local files are deleted, locally-deleted files restored, and un-opened local edits reverted. Opened (checked-out) files are NOT touched. This cannot be undone.": { ru: "Clean приводит воркспейс в точное соответствие депо: лишние локальные файлы удаляются, удалённые локально — восстанавливаются, неоткрытые локальные правки откатываются. Открытые (взятые на редактирование) файлы НЕ трогаются. Отменить нельзя.", de: "Clean bringt den Arbeitsbereich exakt in Übereinstimmung mit dem Depot: zusätzliche lokale Dateien werden gelöscht, lokal gelöschte wiederhergestellt und nicht geöffnete lokale Änderungen zurückgesetzt. Geöffnete (ausgecheckte) Dateien bleiben unberührt. Nicht widerrufbar.", fr: "Clean aligne exactement l'espace de travail sur le dépôt : les fichiers locaux supplémentaires sont supprimés, les fichiers supprimés localement restaurés et les modifications locales non ouvertes annulées. Les fichiers ouverts (extraits) ne sont PAS touchés. Irréversible.", es: "Clean hace que el espacio de trabajo coincida exactamente con el depósito: los archivos locales extra se eliminan, los borrados localmente se restauran y las ediciones locales no abiertas se revierten. Los archivos abiertos (extraídos) NO se tocan. No se puede deshacer." },
"Workspace already matches the depot — nothing to clean.": { ru: "Воркспейс уже соответствует депо — чистить нечего.", de: "Arbeitsbereich stimmt bereits mit dem Depot überein — nichts zu bereinigen.", fr: "L'espace de travail correspond déjà au dépôt — rien à nettoyer.", es: "El espacio de trabajo ya coincide con el depósito — nada que limpiar." },
"Workspace cleaned to match the depot.": { ru: "Воркспейс очищен под депо.", de: "Arbeitsbereich an das Depot angeglichen.", fr: "Espace de travail nettoyé pour correspondre au dépôt.", es: "Espacio de trabajo limpiado para coincidir con el depósito." },
"Clean {n} file(s)": { ru: "Очистить файлов: {n}", de: "{n} Datei(en) bereinigen", fr: "Nettoyer {n} fichier(s)", es: "Limpiar {n} archivo(s)" },
"Submit & keep checked out (-r)": { ru: "Сабмит и оставить открытым (-r)", de: "Übermitteln & ausgecheckt lassen (-r)", fr: "Soumettre et garder extrait (-r)", es: "Enviar y mantener extraído (-r)" },
"Submit, reverting unchanged": { ru: "Сабмит, откатив неизменённые", de: "Übermitteln, Unveränderte zurücksetzen", fr: "Soumettre, annuler les inchangés", es: "Enviar, revirtiendo los sin cambios" },
"Submit shelved (-e)": { ru: "Сабмит из shelve (-e)", de: "Geshelvtes übermitteln (-e)", fr: "Soumettre le remisé (-e)", es: "Enviar lo archivado (-e)" },
"Submit shelved #{n}?": { ru: "Сабмитить отложенное #{n}?", de: "Geshelvtes #{n} übermitteln?", fr: "Soumettre le remisé #{n} ?", es: "¿Enviar lo archivado #{n}?" },
"The shelved files of #{n} are submitted directly from the server.": { ru: "Отложенные файлы #{n} сабмитятся напрямую с сервера.", de: "Die geshelvten Dateien von #{n} werden direkt vom Server übermittelt.", fr: "Les fichiers remisés de #{n} sont soumis directement depuis le serveur.", es: "Los archivos archivados de #{n} se envían directamente desde el servidor." },
"Submitted shelved changelist #{n}.": { ru: "Отправлено отложенное #{n}.", de: "Geshelvter Changelist #{n} übermittelt.", fr: "Changelist remisé #{n} soumis.", es: "Changelist archivado #{n} enviado." },
"Edit .p4ignore…": { ru: "Редактировать .p4ignore…", de: ".p4ignore bearbeiten…", fr: "Modifier .p4ignore…", es: "Editar .p4ignore…" },
"at the workspace root": { ru: "в корне воркспейса", de: "im Arbeitsbereich-Root", fr: "à la racine de l'espace de travail", es: "en la raíz del espacio de trabajo" },
"Files matching these patterns are ignored by reconcile / add. One pattern per line. Requires P4IGNORE to be configured (usually .p4ignore).": { ru: "Файлы под этими паттернами игнорируются при reconcile / add. По одному паттерну на строку. Нужна настроенная переменная P4IGNORE (обычно .p4ignore).", de: "Dateien, die diesen Mustern entsprechen, werden von reconcile / add ignoriert. Ein Muster pro Zeile. Erfordert konfiguriertes P4IGNORE (meist .p4ignore).", fr: "Les fichiers correspondant à ces motifs sont ignorés par reconcile / add. Un motif par ligne. Nécessite P4IGNORE configuré (généralement .p4ignore).", es: "Los archivos que coinciden con estos patrones se ignoran en reconcile / add. Un patrón por línea. Requiere P4IGNORE configurado (normalmente .p4ignore)." },
"Insert Unreal template": { ru: "Вставить шаблон Unreal", de: "Unreal-Vorlage einfügen", fr: "Insérer le modèle Unreal", es: "Insertar plantilla de Unreal" },
".p4ignore saved.": { ru: ".p4ignore сохранён.", de: ".p4ignore gespeichert.", fr: ".p4ignore enregistré.", es: ".p4ignore guardado." },
"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…" },
// ---- offline work + reconcile ----
"Work Offline": { ru: "Работать офлайн", de: "Offline arbeiten", fr: "Travailler hors ligne", es: "Trabajar sin conexión" },
"Reconcile Offline Work…": { ru: "Согласовать офлайн-правки…", de: "Offline-Arbeit abgleichen…", fr: "Réconcilier le travail hors ligne…", es: "Reconciliar trabajo sin conexión…" },
"Reconcile Offline Work": { ru: "Согласование офлайн-правок", de: "Offline-Arbeit abgleichen", fr: "Réconcilier le travail hors ligne", es: "Reconciliar trabajo sin conexión" },
"Working offline — edits stay local. Run Reconcile Offline Work to sync.": { ru: "Работаешь офлайн — правки остаются локально. Запусти «Согласовать офлайн-правки», чтобы синхронизировать.", de: "Offline-Modus — Änderungen bleiben lokal. Führe „Offline-Arbeit abgleichen“ aus, um zu synchronisieren.", fr: "Hors ligne — les modifications restent locales. Lance « Réconcilier le travail hors ligne » pour synchroniser.", es: "Sin conexión — los cambios quedan en local. Ejecuta «Reconciliar trabajo sin conexión» para sincronizar." },
"Back online.": { ru: "Снова онлайн.", de: "Wieder online.", fr: "De nouveau en ligne.", es: "De nuevo en línea." },
"Working offline — edits stay on your disk. Reconcile to bring them into a changelist.": { ru: "Работаешь офлайн — правки лежат на диске. Согласуй, чтобы собрать их в changelist.", de: "Offline — Änderungen liegen auf deiner Festplatte. Gleiche ab, um sie in eine Changelist zu übernehmen.", fr: "Hors ligne — les modifications sont sur votre disque. Réconciliez pour les regrouper dans une changelist.", es: "Sin conexión — los cambios están en tu disco. Reconcilia para llevarlos a una changelist." },
"Reconcile…": { ru: "Согласовать…", de: "Abgleichen…", fr: "Réconcilier…", es: "Reconciliar…" },
"Go online": { ru: "Выйти онлайн", de: "Online gehen", fr: "Passer en ligne", es: "Conectar" },
"Work offline instead": { ru: "Перейти в офлайн", de: "Stattdessen offline arbeiten", fr: "Travailler hors ligne", es: "Trabajar sin conexión" },
"Make writable (offline edit)": { ru: "Сделать редактируемым (офлайн)", de: "Beschreibbar machen (offline)", fr: "Rendre modifiable (hors ligne)", es: "Hacer editable (sin conexión)" },
"No local path for the selection.": { ru: "Нет локального пути для выделения.", de: "Kein lokaler Pfad für die Auswahl.", fr: "Aucun chemin local pour la sélection.", es: "No hay ruta local para la selección." },
"{n} file(s) made writable for offline editing.": { ru: "{n} файл(ов) сделаны редактируемыми для офлайн-правки.", de: "{n} Datei(en) für Offline-Bearbeitung beschreibbar gemacht.", fr: "{n} fichier(s) rendus modifiables pour l'édition hors ligne.", es: "{n} archivo(s) hechos editables para edición sin conexión." },
"These files changed on disk while the server didn't know. Pick which to bring into a pending changelist: added files get p4 add, modified get p4 edit, missing get p4 delete. Nothing is submitted — you review and submit after.": { ru: "Эти файлы изменились на диске без ведома сервера. Выбери, что внести в pending changelist: добавленные → p4 add, изменённые → p4 edit, пропавшие → p4 delete. Ничего не сабмитится — сначала проверишь и отправишь сам.", de: "Diese Dateien haben sich auf der Festplatte geändert, ohne dass der Server es weiß. Wähle, was in eine offene Changelist soll: neue → p4 add, geänderte → p4 edit, fehlende → p4 delete. Nichts wird übermittelt — du prüfst und übermittelst danach.", fr: "Ces fichiers ont changé sur le disque à l'insu du serveur. Choisis ceux à placer dans une changelist en attente : ajoutés → p4 add, modifiés → p4 edit, manquants → p4 delete. Rien n'est soumis — tu vérifies et soumets ensuite.", es: "Estos archivos cambiaron en disco sin que el servidor lo supiera. Elige cuáles llevar a una changelist pendiente: añadidos → p4 add, modificados → p4 edit, faltantes → p4 delete. Nada se envía — revisas y envías después." },
"added": { ru: "добавлено", de: "hinzugefügt", fr: "ajoutés", es: "añadidos" },
"modified": { ru: "изменено", de: "geändert", fr: "modifiés", es: "modificados" },
"deleted": { ru: "удалено", de: "gelöscht", fr: "supprimés", es: "eliminados" },
"Scanning workspace for offline changes…": { ru: "Сканирую воркспейс на офлайн-изменения…", de: "Arbeitsbereich wird auf Offline-Änderungen geprüft…", fr: "Analyse de l'espace de travail pour les modifications hors ligne…", es: "Analizando el espacio de trabajo en busca de cambios sin conexión…" },
"Nothing to reconcile — the workspace matches what the server knows.": { ru: "Согласовывать нечего — воркспейс совпадает с тем, что знает сервер.", de: "Nichts abzugleichen — der Arbeitsbereich stimmt mit dem Server überein.", fr: "Rien à réconcilier — l'espace de travail correspond à ce que le serveur connaît.", es: "Nada que reconciliar — el espacio de trabajo coincide con lo que el servidor conoce." },
"Reconcile {n} file(s)": { ru: "Согласовать {n} файл(ов)", de: "{n} Datei(en) abgleichen", fr: "Réconcilier {n} fichier(s)", es: "Reconciliar {n} archivo(s)" },
"Reconciled {n} file(s) into a changelist.": { ru: "Согласовано {n} файл(ов) в changelist.", de: "{n} Datei(en) in eine Changelist abgeglichen.", fr: "{n} fichier(s) réconciliés dans une changelist.", es: "{n} archivo(s) reconciliados en una changelist." },
// ---- menu tooltips ----
"Change which Perforce workspace (client) you're working in.": { ru: "Сменить воркспейс (client), в котором ты работаешь.", de: "Wechsle den Perforce-Arbeitsbereich (Client), in dem du arbeitest.", fr: "Change l'espace de travail Perforce (client) dans lequel tu travailles.", es: "Cambia el espacio de trabajo de Perforce (client) en el que trabajas." },
"Create a new workspace mapping depot folders to a local folder.": { ru: "Создать новый воркспейс, сопоставив папки депо с локальной папкой.", de: "Erstelle einen neuen Arbeitsbereich, der Depot-Ordner einem lokalen Ordner zuordnet.", fr: "Crée un espace de travail associant des dossiers du depot à un dossier local.", es: "Crea un espacio de trabajo que asigna carpetas del depot a una carpeta local." },
"Edit the current workspace spec — root folder and depot view mapping.": { ru: "Изменить спеку текущего воркспейса — корневую папку и маппинг депо.", de: "Bearbeite die aktuelle Arbeitsbereich-Spezifikation — Root-Ordner und Depot-View-Mapping.", fr: "Modifie la spec de l'espace de travail actuel — dossier racine et mappage du depot.", es: "Edita la spec del espacio de trabajo actual: carpeta raíz y mapeo del depot." },
"Pick which depot folder the app shows and syncs.": { ru: "Выбрать, какую папку депо приложение показывает и синхронизирует.", de: "Wähle, welchen Depot-Ordner die App anzeigt und synchronisiert.", fr: "Choisis quel dossier du depot l'app affiche et synchronise.", es: "Elige qué carpeta del depot muestra y sincroniza la app." },
"Sign out and return to the connection screen.": { ru: "Выйти и вернуться к экрану подключения.", de: "Abmelden und zum Verbindungsbildschirm zurückkehren.", fr: "Se déconnecter et revenir à l'écran de connexion.", es: "Cerrar sesión y volver a la pantalla de conexión." },
"Re-read state from the server.": { ru: "Перечитать состояние с сервера.", de: "Zustand vom Server neu einlesen.", fr: "Relire l'état depuis le serveur.", es: "Volver a leer el estado desde el servidor." },
"Scan the workspace for files changed outside the app (reconcile).": { ru: "Просканировать воркспейс на файлы, изменённые вне приложения (reconcile).", de: "Arbeitsbereich nach außerhalb der App geänderten Dateien durchsuchen (reconcile).", fr: "Analyser l'espace de travail pour les fichiers modifiés hors de l'app (reconcile).", es: "Analiza el espacio de trabajo en busca de archivos cambiados fuera de la app (reconcile)." },
"Toggle offline-work mode: stop polling the server; edit files locally and reconcile later. Just like P4V.": { ru: "Включить/выключить офлайн-режим: не опрашивать сервер, править файлы локально и согласовать позже. Как в P4V.", de: "Offline-Modus umschalten: Server nicht abfragen, Dateien lokal bearbeiten und später abgleichen. Wie in P4V.", fr: "Basculer le mode hors ligne : ne plus interroger le serveur, éditer localement et réconcilier plus tard. Comme dans P4V.", es: "Alternar el modo sin conexión: dejar de consultar el servidor, editar en local y reconciliar después. Como en P4V." },
"Catch up the server with work done while offline: opens local adds / edits / deletes into a changelist so you can submit them.": { ru: "Догнать сервер работой, сделанной офлайн: открывает локальные add / edit / delete в changelist для отправки.", de: "Den Server mit offline erledigter Arbeit nachziehen: öffnet lokale Adds / Edits / Deletes in einer Changelist zum Übermitteln.", fr: "Mettre le serveur à jour avec le travail hors ligne : ouvre les ajouts / modifications / suppressions locaux dans une changelist à soumettre.", es: "Poner al día el servidor con el trabajo sin conexión: abre adds / edits / deletes locales en una changelist para enviarlos." },
"Make the workspace exactly match the depot — discards un-opened local changes.": { ru: "Привести воркспейс точно к состоянию депо — отбрасывает неоткрытые локальные правки.", de: "Arbeitsbereich exakt an das Depot angleichen — verwirft nicht geöffnete lokale Änderungen.", fr: "Aligner exactement l'espace de travail sur le depot — abandonne les modifications locales non ouvertes.", es: "Hacer que el espacio de trabajo coincida exactamente con el depot — descarta cambios locales no abiertos." },
"Sync the workspace to the newest revision (pull).": { ru: "Синхронизировать воркспейс до последней ревизии (pull).", de: "Arbeitsbereich auf die neueste Revision synchronisieren (Pull).", fr: "Synchroniser l'espace de travail sur la dernière révision (pull).", es: "Sincronizar el espacio de trabajo a la revisión más reciente (pull)." },
"Sync the workspace to a specific changelist, label or date.": { ru: "Синхронизировать воркспейс до конкретного changelist, метки или даты.", de: "Arbeitsbereich auf eine bestimmte Changelist, ein Label oder ein Datum synchronisieren.", fr: "Synchroniser l'espace de travail sur une changelist, un label ou une date précis.", es: "Sincronizar el espacio de trabajo a una changelist, etiqueta o fecha concretas." },
"Save selected files into a local pending changelist (not sent yet).": { ru: "Сохранить выбранные файлы в локальный pending changelist (пока не отправлено).", de: "Ausgewählte Dateien in eine lokale offene Changelist speichern (noch nicht gesendet).", fr: "Enregistrer les fichiers sélectionnés dans une changelist locale en attente (pas encore envoyée).", es: "Guardar los archivos seleccionados en una changelist local pendiente (aún no enviada)." },
"Send pending changelists to the server (push).": { ru: "Отправить pending changelists на сервер (push).", de: "Offene Changelists an den Server senden (Push).", fr: "Envoyer les changelists en attente au serveur (push).", es: "Enviar las changelists pendientes al servidor (push)." },
"Resolve merge conflicts left after a sync.": { ru: "Разрешить конфликты слияния, оставшиеся после синхронизации.", de: "Nach einer Synchronisierung verbliebene Merge-Konflikte auflösen.", fr: "Résoudre les conflits de fusion restants après une synchronisation.", es: "Resolver los conflictos de fusión que quedan tras una sincronización." },
"Discard every open change and restore depot versions.": { ru: "Отменить все открытые правки и восстановить версии из депо.", de: "Alle offenen Änderungen verwerfen und Depot-Versionen wiederherstellen.", fr: "Abandonner toutes les modifications ouvertes et restaurer les versions du depot.", es: "Descartar todos los cambios abiertos y restaurar las versiones del depot." },
"Show the panel of recently run Perforce commands.": { ru: "Показать панель недавно выполненных команд Perforce.", de: "Zeige das Panel der zuletzt ausgeführten Perforce-Befehle.", fr: "Afficher le panneau des commandes Perforce récemment exécutées.", es: "Mostrar el panel de comandos de Perforce ejecutados recientemente." },
"Open an embedded terminal for raw p4 commands.": { ru: "Открыть встроенный терминал для сырых команд p4.", de: "Ein eingebettetes Terminal für rohe p4-Befehle öffnen.", fr: "Ouvrir un terminal intégré pour les commandes p4 brutes.", es: "Abrir una terminal integrada para comandos p4 en crudo." },
"Show output from the headless Unreal preview process.": { ru: "Показать вывод фонового процесса Unreal-превью.", de: "Ausgabe des Headless-Unreal-Vorschauprozesses anzeigen.", fr: "Afficher la sortie du processus de prévisualisation Unreal sans interface.", es: "Mostrar la salida del proceso de vista previa de Unreal sin interfaz." },
"See every file currently exclusively locked and by whom.": { ru: "Посмотреть все файлы, сейчас взятые на эксклюзивный лок, и кем.", de: "Alle aktuell exklusiv gesperrten Dateien und von wem sehen.", fr: "Voir tous les fichiers actuellement verrouillés en exclusivité et par qui.", es: "Ver todos los archivos bloqueados en exclusiva ahora mismo y por quién." },
"Find files anywhere in the depot by name or path.": { ru: "Найти файлы в любом месте депо по имени или пути.", de: "Dateien überall im Depot nach Name oder Pfad finden.", fr: "Trouver des fichiers n'importe où dans le depot par nom ou chemin.", es: "Buscar archivos en cualquier lugar del depot por nombre o ruta." },
"Set which binary asset types are exclusive-checkout (+l) so only one person edits them.": { ru: "Задать, какие бинарные типы ассетов берутся эксклюзивно (+l), чтобы их правил только один человек.", de: "Festlegen, welche binären Asset-Typen exklusiv ausgecheckt werden (+l), damit nur eine Person sie bearbeitet.", fr: "Définir quels types d'assets binaires sont en extraction exclusive (+l) pour qu'une seule personne les édite.", es: "Definir qué tipos de assets binarios son de extracción exclusiva (+l) para que solo una persona los edite." },
"Named snapshots of file revisions: tag files, or sync to a label.": { ru: "Именованные снимки ревизий файлов: пометить файлы или синхронизироваться на метку.", de: "Benannte Schnappschüsse von Dateirevisionen: Dateien taggen oder auf ein Label synchronisieren.", fr: "Instantanés nommés de révisions de fichiers : taguer des fichiers ou synchroniser sur un label.", es: "Instantáneas con nombre de revisiones de archivos: etiquetar archivos o sincronizar a una etiqueta." },
"Move changes between branches of the depot.": { ru: "Переносить изменения между ветками депо.", de: "Änderungen zwischen Depot-Branches verschieben.", fr: "Déplacer des modifications entre les branches du depot.", es: "Mover cambios entre ramas del depot." },
"Switch stream, merge down from the parent, or copy up to it.": { ru: "Переключить стрим, влить сверху от родителя (merge down) или протолкнуть наверх (copy up).", de: "Stream wechseln, vom Parent mergen (merge down) oder nach oben kopieren (copy up).", fr: "Changer de stream, fusionner depuis le parent (merge down) ou remonter (copy up).", es: "Cambiar de stream, fusionar desde el padre (merge down) o copiar hacia arriba (copy up)." },
"Perforce's task/bug tracker — create jobs and attach them to changelists.": { ru: "Трекер задач/багов Perforce — создавай джобы и привязывай к changelists.", de: "Perforce-Aufgaben-/Bug-Tracker — Jobs erstellen und an Changelists anhängen.", fr: "Le suivi de tâches/bugs de Perforce — crée des jobs et attache-les aux changelists.", es: "El rastreador de tareas/errores de Perforce — crea jobs y adjúntalos a changelists." },
"Edit which files reconcile / add ignore (build output, caches).": { ru: "Изменить, какие файлы игнорируют reconcile / add (сборка, кэши).", de: "Bearbeiten, welche Dateien reconcile / add ignorieren (Build-Ausgabe, Caches).", fr: "Modifier quels fichiers reconcile / add ignorent (sortie de build, caches).", es: "Editar qué archivos ignoran reconcile / add (salida de compilación, cachés)." },
"Compile the Visual Studio solution found in the working folder.": { ru: "Скомпилировать решение Visual Studio, найденное в рабочей папке.", de: "Die im Arbeitsordner gefundene Visual-Studio-Solution kompilieren.", fr: "Compiler la solution Visual Studio trouvée dans le dossier de travail.", es: "Compilar la solución de Visual Studio encontrada en la carpeta de trabajo." },
"See who works on this depot and their roles.": { ru: "Посмотреть, кто работает с этим депо и их роли.", de: "Sehen, wer an diesem Depot arbeitet und welche Rollen sie haben.", fr: "Voir qui travaille sur ce depot et leurs rôles.", es: "Ver quién trabaja en este depot y sus roles." },
"App preferences — language, theme, editor, and more.": { ru: "Настройки приложения — язык, тема, редактор и прочее.", de: "App-Einstellungen — Sprache, Design, Editor und mehr.", fr: "Préférences de l'app — langue, thème, éditeur et plus.", es: "Preferencias de la app — idioma, tema, editor y más." },
"Version and information about Exbyte Depot.": { ru: "Версия и информация о Exbyte Depot.", de: "Version und Informationen zu Exbyte Depot.", fr: "Version et informations sur Exbyte Depot.", es: "Versión e información sobre Exbyte Depot." },
// ---- files & sizes explorer ----
"Files & Sizes…": { ru: "Файлы и размеры…", de: "Dateien & Größen…", fr: "Fichiers et tailles…", es: "Archivos y tamaños…" },
"Files & Sizes": { ru: "Файлы и размеры", de: "Dateien & Größen", fr: "Fichiers et tailles", es: "Archivos y tamaños" },
"Browse the Depot and your Workspace as a tree and see how much every file and folder weighs.": { ru: "Просматривай Depot и свой Workspace деревом и смотри, сколько весит каждый файл и папка.", de: "Durchsuche das Depot und deinen Arbeitsbereich als Baum und sieh, wie viel jede Datei und jeder Ordner wiegt.", fr: "Parcours le Depot et ton espace de travail sous forme d'arbre et vois le poids de chaque fichier et dossier.", es: "Explora el Depot y tu espacio de trabajo como un árbol y ve cuánto pesa cada archivo y carpeta." },
"Depot": { ru: "Depot", de: "Depot", fr: "Depot", es: "Depot" },
"Whole depot": { ru: "Весь depot", de: "Gesamtes Depot", fr: "Depot entier", es: "Todo el depot" },
"Workspace root": { ru: "Корень workspace", de: "Arbeitsbereich-Root", fr: "Racine de l'espace de travail", es: "Raíz del espacio de trabajo" },
"{n} items": { ru: "{n} эл.", de: "{n} Einträge", fr: "{n} éléments", es: "{n} elementos" },
"Weighing…": { ru: "Считаю вес…", de: "Wird gewogen…", fr: "Calcul du poids…", es: "Calculando tamaño…" },
"Empty.": { ru: "Пусто.", de: "Leer.", fr: "Vide.", es: "Vacío." },
"Sizes come from the server (p4 sizes): the total bytes each depot folder stores across all its files. Sorted heaviest first.": { ru: "Размеры берутся с сервера (p4 sizes): суммарный объём, который каждая папка депо хранит по всем файлам. Сортировка по убыванию веса.", de: "Größen kommen vom Server (p4 sizes): die Gesamtbytes, die jeder Depot-Ordner über alle Dateien speichert. Nach Größe absteigend sortiert.", fr: "Les tailles viennent du serveur (p4 sizes) : le total d'octets que chaque dossier du depot stocke sur tous ses fichiers. Trié du plus lourd au plus léger.", es: "Los tamaños vienen del servidor (p4 sizes): el total de bytes que cada carpeta del depot almacena en todos sus archivos. Ordenado de mayor a menor." },
"Sizes are measured on your disk: how much each folder and file of your local workspace actually takes up. Sorted heaviest first.": { ru: "Размеры измеряются на твоём диске: сколько реально занимает каждая папка и файл локального workspace. Сортировка по убыванию веса.", de: "Größen werden auf deiner Festplatte gemessen: wie viel jeder Ordner und jede Datei deines lokalen Arbeitsbereichs tatsächlich belegt. Nach Größe absteigend sortiert.", fr: "Les tailles sont mesurées sur ton disque : l'espace réellement occupé par chaque dossier et fichier de ton espace de travail local. Trié du plus lourd au plus léger.", es: "Los tamaños se miden en tu disco: cuánto ocupa realmente cada carpeta y archivo de tu espacio de trabajo local. Ordenado de mayor a menor." },
"Browse the Depot and your Workspace as a tree and see how much every file and folder weighs. Sorted heaviest first.": { ru: "Просматривай Depot и Workspace деревом и смотри вес каждого файла и папки. Сортировка по убыванию.", de: "Durchsuche das Depot und deinen Arbeitsbereich als Baum und sieh das Gewicht jeder Datei und jedes Ordners. Nach Größe absteigend.", fr: "Parcours le Depot et ton espace de travail en arbre et vois le poids de chaque fichier et dossier. Trié du plus lourd au plus léger.", es: "Explora el Depot y tu espacio de trabajo como árbol y ve el peso de cada archivo y carpeta. De mayor a menor." },
// ---- view panel (File Viewer / File Tree tabs) ----
"File Viewer": { ru: "Просмотр файла", de: "Dateivorschau", fr: "Visionneuse", es: "Visor de archivos" },
"File Tree": { ru: "Дерево файлов", de: "Dateibaum", fr: "Arborescence", es: "Árbol de archivos" },
"No panel open.\nAdd File Viewer or File Tree from the Window menu.": { ru: "Нет открытых панелей.\nДобавь «Просмотр файла» или «Дерево файлов» из меню Window.", de: "Kein Panel geöffnet.\nFüge „Dateivorschau“ oder „Dateibaum“ über das Menü Window hinzu.", fr: "Aucun panneau ouvert.\nAjoute « Visionneuse » ou « Arborescence » depuis le menu Window.", es: "Ningún panel abierto.\nAñade «Visor de archivos» o «Árbol de archivos» desde el menú Window." },
"The preview panel for the selected file (image, 3D model, code, diff).": { ru: "Панель предпросмотра выбранного файла (картинка, 3D-модель, код, diff).", de: "Das Vorschau-Panel für die ausgewählte Datei (Bild, 3D-Modell, Code, Diff).", fr: "Le panneau de prévisualisation du fichier sélectionné (image, modèle 3D, code, diff).", es: "El panel de vista previa del archivo seleccionado (imagen, modelo 3D, código, diff)." },
"A folder hierarchy of the Depot and your Workspace, docked into the view panel.": { ru: "Иерархия папок Depot и Workspace, встроенная в панель просмотра.", de: "Eine Ordnerhierarchie des Depots und deines Arbeitsbereichs, angedockt im Ansichts-Panel.", fr: "Une hiérarchie de dossiers du Depot et de ton espace de travail, ancrée dans le panneau.", es: "Una jerarquía de carpetas del Depot y tu espacio de trabajo, anclada en el panel." },
"Show in File Tree": { ru: "Показать в дереве файлов", de: "Im Dateibaum zeigen", fr: "Afficher dans l'arborescence", es: "Mostrar en el árbol de archivos" },
"Total size of files in this changelist": { ru: "Суммарный вес файлов в этом ченджлисте", de: "Gesamtgröße der Dateien in dieser Changelist", fr: "Taille totale des fichiers de cette changelist", es: "Tamaño total de los archivos de esta changelist" },
// ---- server activity / behind + tray notifications ----
"Server has newer content — {n} file(s) behind.": { ru: "На сервере есть свежее — ты отстал на {n} файл(ов).", de: "Der Server hat neueren Inhalt — {n} Datei(en) im Rückstand.", fr: "Le serveur a du contenu plus récent — {n} fichier(s) en retard.", es: "El servidor tiene contenido más nuevo — {n} archivo(s) por detrás." },
"Your workspace is behind": { ru: "Твой воркспейс отстал", de: "Dein Arbeitsbereich ist veraltet", fr: "Ton espace de travail est en retard", es: "Tu espacio de trabajo está desactualizado" },
"{n} file(s) on the server are newer than your copy. Get Latest to update.": { ru: "{n} файл(ов) на сервере новее твоих. Нажми Get Latest, чтобы обновить.", de: "{n} Datei(en) auf dem Server sind neuer als deine. Get Latest zum Aktualisieren.", fr: "{n} fichier(s) sur le serveur sont plus récents que les tiens. Fais Get Latest pour mettre à jour.", es: "{n} archivo(s) del servidor son más nuevos que los tuyos. Pulsa Get Latest para actualizar." },
"Exbyte Depot is still running": { ru: "Exbyte Depot всё ещё работает", de: "Exbyte Depot läuft weiter", fr: "Exbyte Depot fonctionne toujours", es: "Exbyte Depot sigue en ejecución" },
"It's in the system tray and will notify you about teammates' submits. Right-click the tray icon to quit.": { ru: "Он в системном трее и будет присылать уведомления о сабмитах коллег. ПКМ по иконке в трее — выход.", de: "Es liegt im System-Tray und benachrichtigt dich über Submits von Kollegen. Rechtsklick auf das Tray-Symbol zum Beenden.", fr: "Il est dans la zone de notification et t'avertira des soumissions de tes collègues. Clic droit sur l'icône pour quitter.", es: "Está en la bandeja del sistema y te avisará de los envíos de tus compañeros. Clic derecho en el icono para salir." },
// ---- audio/video preview + docked locks ----
"Audio": { ru: "Аудио", de: "Audio", fr: "Audio", es: "Audio" },
"Video": { ru: "Видео", de: "Video", fr: "Vidéo", es: "Vídeo" },
"Duration": { ru: "Длительность", de: "Dauer", fr: "Durée", es: "Duración" },
"server revision": { ru: "ревизия с сервера", de: "Server-Revision", fr: "révision serveur", es: "revisión del servidor" },
"Everyone's checked-out / exclusively-locked files, docked into the view panel.": { ru: "Все чекнутые / эксклюзивно залоченные файлы всех участников, встроенные в панель просмотра.", de: "Alle ausgecheckten / exklusiv gesperrten Dateien aller Nutzer, angedockt im Ansichts-Panel.", fr: "Les fichiers extraits / verrouillés en exclusivité de tout le monde, ancrés dans le panneau.", es: "Los archivos extraídos / bloqueados en exclusiva de todos, anclados en el panel." },
};
export function t(en: string, vars?: Record<string, string | number>): string {

121
src/p4.ts
View File

@ -64,6 +64,26 @@ export const p4 = {
history: (path: string) => invoke<Change[]>("p4_history", { path }),
disconnect: () => invoke<void>("p4_disconnect"),
sync: (scope = "") => invoke<string>("p4_sync", { scope }),
syncTo: (scope: string, rev: string) => invoke<string>("p4_sync_to", { scope, rev }),
labels: () => invoke<{ label?: string; Update?: string; Owner?: string; Description?: string; [k: string]: unknown }[]>("p4_labels"),
labelTag: (label: string, scope: string, rev = "") => invoke<string>("p4_label_tag", { label, scope, rev }),
branchOp: (op: "merge" | "copy" | "integrate", source: string, target: string) => invoke<string>("p4_branch_op", { op, source, target }),
streams: () => invoke<{ Stream?: string; Name?: string; Type?: string; Parent?: string; [k: string]: unknown }[]>("p4_streams"),
switchStream: (stream: string) => invoke<P4Info>("p4_switch_stream", { stream }),
streamInteg: (stream: string, direction: "down" | "up") => invoke<string>("p4_stream_integ", { stream, direction }),
filelog: (depot: string) => invoke<Record<string, unknown>>("p4_filelog", { depot }),
cleanPreview: (scope = "") => invoke<OpenedFile[]>("p4_clean_preview", { scope }),
cleanApply: (scope = "") => invoke<string>("p4_clean_apply", { scope }),
depotLs: (path = "") => invoke<FsEntry[]>("depot_ls", { path }),
changeSizes: (changes: string[], scope = "") => invoke<{ change: string; size: number }[]>("p4_change_sizes", { changes, scope }),
fsLs: (path = "") => invoke<FsEntry[]>("fs_ls", { path }),
reconcilePreview: (scope = "") => invoke<OpenedFile[]>("p4_reconcile_preview", { scope }),
reconcileApply: (paths: string[]) => invoke<OpenedFile[]>("p4_reconcile_apply", { paths }),
setWritable: (paths: string[], writable: boolean) => invoke<string>("p4_set_writable", { paths, writable }),
jobs: () => invoke<{ Job?: string; Status?: string; Description?: string; User?: string; [k: string]: unknown }[]>("p4_jobs"),
fix: (job: string, change: string) => invoke<string>("p4_fix", { job, change }),
jobSpec: (job: string) => invoke<string>("p4_job_spec", { job }),
jobSave: (spec: string) => invoke<string>("p4_job_save", { spec }),
diff: (file: string) => invoke<string>("p4_diff", { file }),
revert: (files: string[]) => invoke<string>("p4_revert", { files }),
submit: (description: string, files: string[]) =>
@ -71,6 +91,10 @@ export const p4 = {
commit: (description: string, files: string[]) =>
invoke<string>("p4_commit", { description, files }),
submitChange: (change: string) => invoke<string>("p4_submit_change", { change }),
submitShelved: (change: string) => invoke<string>("p4_submit_shelved", { change }),
submitOpts: (change: string, reopen: boolean, revertUnchanged: boolean) => invoke<string>("p4_submit_opts", { change, reopen, revertUnchanged }),
ignoreRead: () => invoke<string>("p4_ignore_read"),
ignoreWrite: (content: string) => invoke<void>("p4_ignore_write", { content }),
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 }),
@ -85,7 +109,8 @@ export const p4 = {
findSln: (scope: string) => invoke<string>("find_sln", { scope }),
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;
@ -101,16 +126,65 @@ export const p4 = {
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 }),
clientSpec: (client: string) => invoke<string>("p4_client_spec", { client }),
clientSave: (spec: string) => invoke<string>("p4_client_save", { spec }),
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 }),
typemapGet: () => invoke<string[]>("p4_typemap_get"),
typemapSet: (entries: string[]) => invoke<string>("p4_typemap_set", { entries }),
reopenType: (files: string[], filetype: string) => invoke<string>("p4_reopen_type", { files, filetype }),
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 }),
behind: (scope = "") => invoke<{ count: number; sample: string[] }>("p4_behind", { 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 {
@ -119,6 +193,37 @@ export function leaf(path?: string): string {
return p.slice(p.lastIndexOf("/") + 1);
}
// per-file revision history parsed from p4 filelog's indexed fields
export interface FileRev { rev: string; change: string; action: string; user: string; time: string; desc: string; type: string }
/** One entry (file or folder) in the Depot/Workspace size explorer. `size` is bytes. */
export interface FsEntry { name: string; path: string; isDir: boolean; size: number; fileCount: number }
/** Human-readable byte size, e.g. 1536 → "1.5 KB". */
export function humanSize(n: number): string {
if (!n) return "0 B";
if (n < 1024) return `${n} B`;
const u = ["KB", "MB", "GB", "TB", "PB"];
let v = n, i = -1;
do { v /= 1024; i++; } while (v >= 1024 && i < u.length - 1);
return `${v >= 100 ? Math.round(v) : v.toFixed(1)} ${u[i]}`;
}
export function filelogRevs(v: Record<string, unknown>): FileRev[] {
const out: FileRev[] = [];
for (let i = 0; ; i++) {
const rev = v[`rev${i}`] as string | undefined;
if (rev == null) break;
out.push({
rev,
change: (v[`change${i}`] as string) || "",
action: (v[`action${i}`] as string) || "",
user: (v[`user${i}`] as string) || "",
time: (v[`time${i}`] as string) || "",
desc: (v[`desc${i}`] as string) || "",
type: (v[`type${i}`] as string) || "",
});
}
return out;
}
// a submitted changelist with indexed file fields (depotFile0, action0, ...)
export interface Describe extends Change { [k: string]: unknown }
export function describeFiles(d: Describe): { depotFile: string; action: string; rev: string }[] {
@ -178,11 +283,23 @@ export function splitPath(p?: string): { name: string; dir: string } {
}
// classify by extension for the preview panel
export type Kind = "model" | "uasset" | "image" | "code";
export type Kind = "model" | "uasset" | "image" | "audio" | "video" | "code";
export function kindOf(name: string): Kind {
const n = name.toLowerCase();
if (/\.(fbx|obj|gltf|glb|stl|ply|3mf|dae)$/.test(n)) return "model";
if (/\.(uasset|umap)$/.test(n)) return "uasset";
if (/\.(png|jpe?g|webp|gif|bmp|svg)$/.test(n)) return "image";
if (/\.(wav|mp3|ogg|flac|m4a|aac|opus)$/.test(n)) return "audio";
if (/\.(mp4|webm|mov|m4v|ogv)$/.test(n)) return "video";
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);
}