Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 597dffe92d | |||
| 30e77664d5 | |||
| 23fabe11ec | |||
| 37f5c504f7 | |||
| 20c94ef9f0 | |||
| dc88269baf | |||
| 9fc81015d7 | |||
| 294d10eb40 | |||
| 8e7908ae09 | |||
| f0fc2f687a | |||
| 4e4e0f215a | |||
| 09b8f9d842 | |||
| 1c1153eee9 | |||
| 3954082e8d | |||
| 5652f489a2 | |||
| 9c24d45bf9 | |||
| ec8f31c654 | |||
| 5d647f8e92 | |||
| 28a3989095 | |||
| ecc5b8e9bf | |||
| 2069f0fed4 | |||
| 7092c9d1dc | |||
| fdaf126c00 | |||
| 2811aec6e8 | |||
| 6cfeb27bdd | |||
| 0fc0af38da | |||
| e2ad438ed4 | |||
| b40358e0f5 | |||
| 29303cbf43 | |||
| c175d734d7 | |||
| 0da836b0ac | |||
| 6c16d4a4f4 |
4
.gitignore
vendored
@ -22,3 +22,7 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Secrets — never commit keys (OpenRouter key is baked at build time from src-tauri/openrouter.key)
|
||||
*.key
|
||||
latest.json
|
||||
|
||||
94
HotTODO.md
Normal file
@ -0,0 +1,94 @@
|
||||
# 🔥 HotTODO — Exbyte Depot
|
||||
|
||||
Фичи «супер-удобства», которых **нет ни в P4V, ни в git** — заточены под UE-команду на бинарных ассетах. Ранжировано по «польза / усилие». Стек, на который опираемся: Tauri (Rust) backend + `p4` CLI, React фронт, встроенный OpenRouter (AI), UE-MCP (`mcp__ue-blueprint__*`), уже готовые тумбы `.uasset` / 3D-вьювер / медиа-превью / файл-локи / трей+уведомления / offline / Files & Sizes / View-дерево.
|
||||
|
||||
Статус: **план**, реализуем по мере выбора.
|
||||
|
||||
---
|
||||
|
||||
## 🧩 Foundation — система плагинов / аддонов
|
||||
**Идея:** фичи выпускаются как **отдельные плагины**, которые докачиваются по необходимости и не засоряют основной софт. Ядро остаётся лёгким; тяжёлое/нишевое (в т.ч. фичи 1–8 ниже) живёт в плагинах. Это фундамент — стоит сделать рано, тогда остальное можно поставлять как плагины.
|
||||
|
||||
**Реалистичный дизайн под Tauri** (Rust-бэкенд скомпилирован, поэтому плагины — на фронте/сайдкаре, а не нативные .dll):
|
||||
|
||||
- **Тип плагина = JS/ESM-бандл** (± WASM для тяжёлого), грузится в webview в рантайме через **стабильный Host SDK**, который экспортит ядро:
|
||||
- `p4.*` (whitelisted-команды), события (`p4-log`, `p4-transfer`, `p4-scan`…), `flash/notify`, чтение выбранного файла/CL, i18n, тема.
|
||||
- **Contribution points** — куда плагин встраивается: пункт меню Actions, контекст-меню файла/чейнджлиста, вкладка view-панели (у нас уже есть File Viewer/Tree/Locks — тот же механизм), кнопка в тулбаре, шаг перед Submit (hook). Ядро уже построено на этих точках — надо просто открыть их как API.
|
||||
- **Манифест** `plugin.json`: `name, version, entry, minAppVersion, permissions[], contributes{menus,panels,contextItems,submitHooks}`.
|
||||
- **Модель разрешений:** плагин декларирует, что ему нужно (`p4:read`, `p4:write`, `fs:read`, `network:<host>`, `ui:panel`). Пользователь подтверждает при установке. JS-песочница: iframe/Web Worker с message-bridge, доступ к ядру только через Host SDK (не напрямую к `invoke`).
|
||||
- **CSP:** у нас строгий CSP (внешнее запрещено), поэтому плагины **не грузятся с чужих URL** — их скачивают в локальную папку `plugins/` и подключают контролируемо; сетевой доступ плагину даётся только к явно разрешённым хостам.
|
||||
- **Реестр/маркет:** индекс плагинов на Gitea (список манифестов) → скачивание **подписанного** бандла (у нас уже есть инфраструктура подписи minisign) → проверка подписи → распаковка в `plugins/`. UI «Плагины»: установлено / доступно / обновления / вкл-выкл, версии, разрешения.
|
||||
- **Изоляция сбоев:** упавший плагин не роняет ядро (ловим ошибки на границе bridge), можно отключить.
|
||||
- **Сайдкар-вариант** (опционально, для бэкенд-тяжёлых плагинов): плагин = отдельный исполняемый/скрипт, ядро дёргает его по stdio-JSON (как VS Code language servers). Полезно для парсеров `.uasset`, интеграций.
|
||||
|
||||
**Порядок реализации фундамента:**
|
||||
1. Внутренний рефактор: вынести существующие contribution-точки (меню/контекст/view-табы/submit-hook) за стабильный интерфейс.
|
||||
2. Host SDK + загрузчик локальных плагинов из `plugins/` + модель разрешений.
|
||||
3. UI «Плагины» (вкл/выкл, разрешения).
|
||||
4. Реестр на Gitea + скачивание/проверка подписи/обновления.
|
||||
5. Перенести 1–2 фичи из списка ниже в плагины как эталон (напр. визуальный дифф или reference-guard).
|
||||
|
||||
**Усилие:** высокое (архитектура), но окупается: дальше фичи добавляются без раздувания ядра и обновляются независимо от основного релиза.
|
||||
|
||||
---
|
||||
|
||||
## 🥇 Топ — уникальное, максимум пользы
|
||||
|
||||
### 1. Reference-guard: целостность ассет-референсов перед Submit ⭐ рекомендация №1
|
||||
Перед сабмитом парсить импорты `.uasset` (через UE asset registry / UE-MCP, либо чтение заголовков `.uasset`) и проверять: не сабмитишь ли ассет, который ссылается на другой ассет, которого **нет ни в депоте, ни в текущем changelist**.
|
||||
- **Зачем:** причина №1 сломанных билдов в UE. Ни P4V, ни git не понимают референсы ассетов.
|
||||
- **UX:** «⚠️ `M_Hero` ссылается на `T_Hero_D`, его нет на сервере и не в коммите → добавить в changelist / отменить сабмит».
|
||||
- **Как:** backend-команда `p4_check_refs(files)` → карта референсов → сверка с `p4 files` + открытыми. Бонус: «submit together» — сгруппировать взаимозависимые ассеты в один атомарный CL.
|
||||
- **Усилие:** среднее-высокое (парсинг референсов — ключевой кусок).
|
||||
|
||||
### 2. Визуальный дифф бинарников
|
||||
Не «binary files differ», а реально:
|
||||
- **Картинки** (png/tga/exr/bmp): сравнение свайпом / onion-skin / попиксельная подсветка разницы.
|
||||
- **`.uasset`**: дифф по свойствам (какой параметр материала / LOD / настройку меняли) из встроенных данных.
|
||||
- **Зачем:** P4V/git по бинарям молчат; Anchorpoint умеет частично.
|
||||
- **Как:** переиспользовать существующие тумбы/`readDepot`; для двух ревизий — `p4 print` обеих версий → canvas-свайп. Свойства `.uasset` — через UE-MCP или парсер.
|
||||
- **Усилие:** картинки — низкое-среднее (быстрый вин); свойства `.uasset` — среднее.
|
||||
|
||||
### 3. Lock handoff — запрос залоченного файла у коллеги
|
||||
Бинарь эксклюзивно залочен напарником → кнопка «Попросить файл». Ему — нативное уведомление (трей уже есть); отпустил — тебе прилетает «свободно».
|
||||
- **Зачем:** убирает «отпусти плиз ассет» из дискорда. Нет нигде.
|
||||
- **Как:** нужен лёгкий канал сообщений между клиентами. Варианты: свой мини-эндпоинт (как waitlist-server) / Perforce как транспорт (спец. changelist-описание или counter) / общий канал. Требует продумать транспорт.
|
||||
- **Усилие:** среднее (основное — транспорт уведомлений между людьми).
|
||||
|
||||
---
|
||||
|
||||
## 🥈 Осведомлённость (git/P4V не дают)
|
||||
|
||||
### 4. Presence — кто где работает прямо сейчас
|
||||
Лёгкий поллинг `p4 opened -a` + свой heartbeat → «Mira: Content/Characters, Voss: Config». Presence как в Google Docs, но по депоту. Предупреждает о коллизии **до** открытия того же ассета.
|
||||
- **Усилие:** низкое-среднее (частично уже есть `others`-индикаторы; нужен агрегированный вид + heartbeat).
|
||||
|
||||
### 5. Impact-preview перед Get Latest
|
||||
Показать не только «сколько файлов», а что это значит для UE: «синк тронет 400 материалов → ~20 мин компиляции шейдеров», «меняется .ini упаковки», «конфликт с твоими открытыми файлами».
|
||||
- **Как:** `p4 sync -n` (уже есть `p4_behind`) + классификация путей/типов; для шейдеров — эвристика по кол-ву материалов/шейдеров.
|
||||
- **Усилие:** среднее.
|
||||
|
||||
---
|
||||
|
||||
## 🥉 Скорость/безопасность (кусочки уже есть)
|
||||
|
||||
### 6. AI-ревью changelist перед Submit
|
||||
OpenRouter уже вшит (commit-месседжи) — расширить: «кладёшь 2 ГБ несжатого ассета», «забыл `.uasset`, на который ссылается материал» (пересекается с №1), «этот тип обычно лочат — взять лок?».
|
||||
- **Усилие:** низкое-среднее (инфра AI готова).
|
||||
|
||||
### 7. Управление занятым местом на диске
|
||||
Есть Files & Sizes — добавить «синкнуто 380 ГБ; вот что можно `p4 sync #none`, чтобы освободить 120 ГБ». Пересекается с View-деревом (исключил → освободил).
|
||||
- **Усилие:** низкое (есть `fs_ls` / sizes).
|
||||
|
||||
### 8. Time-travel превью ассета
|
||||
Ползунок по истории ревизии → тумба/3D на каждой версии. Есть revision graph + тумбы — соединить в визуальную ленту.
|
||||
- **Усилие:** среднее.
|
||||
|
||||
---
|
||||
|
||||
## Порядок, который предлагаю
|
||||
Развилка по стратегии:
|
||||
- **Вариант A (плагины сначала):** сделать 🧩 Foundation, затем каждую фичу — как плагин. Ядро остаётся лёгким с самого начала, но первый результат для пользователя дольше.
|
||||
- **Вариант B (ценность сначала):** сделать 1–2 фичи инлайн ради быстрого «вау», потом построить плагин-систему и вынести их в плагины как эталон. Быстрее польза, но потом рефактор.
|
||||
|
||||
Рекомендую **B → потом Foundation**: сначала №2 (визуальный дифф картинок свайпом — быстрый «вау» на готовой инфре), затем №1 (reference-guard — главная польза для билдов), **после этого 🧩 Foundation** и перенос №1/№2 в плагины как образец. Дальше по выбору: №4 presence → №6 AI-ревью → №5 impact-preview → №7 диск → №3 lock handoff → №2 `.uasset`-дифф свойств → №8 time-travel — уже как плагины.
|
||||
114
PLUGINS.md
Normal file
@ -0,0 +1,114 @@
|
||||
# Exbyte Depot — Plugins
|
||||
|
||||
Plugins add features on top of the core app so the base stays lean and specialised
|
||||
work ships (and updates) independently.
|
||||
|
||||
## What language?
|
||||
**JavaScript / TypeScript.** A plugin is a plain **ES module** that exports
|
||||
`activate(host)`. Author in TypeScript if you like, then compile to a single JS
|
||||
file (`esbuild index.ts --bundle --format=esm --outfile=index.js`). No build step
|
||||
is required for plain JS.
|
||||
|
||||
Why JS and not native? The app's Rust backend is compiled and sealed; the extensible
|
||||
surface is the webview. Plugins run in the app's webview and reach the app **only**
|
||||
through the `host` object — never `invoke` directly.
|
||||
|
||||
## How plugins are loaded
|
||||
The app reads the plugin's entry source from disk (Rust) and imports it as an ES
|
||||
module from a `blob:` URL — **no `eval`**, and it stays inside the CSP
|
||||
(`script-src 'self' blob:`). Disabled plugins are never imported.
|
||||
|
||||
## Anatomy
|
||||
```
|
||||
my-plugin/
|
||||
plugin.json # manifest
|
||||
index.js # ES module exporting activate(host)
|
||||
```
|
||||
|
||||
### plugin.json
|
||||
```json
|
||||
{
|
||||
"id": "my-plugin", // unique; [A-Za-z0-9-_.]
|
||||
"name": "My Plugin",
|
||||
"version": "1.0.0",
|
||||
"author": "You",
|
||||
"description": "What it does.",
|
||||
"entry": "index.js", // must stay inside the plugin folder
|
||||
"minAppVersion": "0.3.5",
|
||||
"permissions": ["p4:read", "notify"],
|
||||
"contributes": { "menu": true, "views": ["notes"], "fileContext": true, "submitHooks": true }
|
||||
}
|
||||
```
|
||||
|
||||
### Permissions (declared, shown to the user)
|
||||
| permission | grants |
|
||||
|---|---|
|
||||
| `p4:read` | read-only p4: `opened, dirs, describe, changeSizes, clientSpec` on `host.p4` |
|
||||
| `p4:write` | mutating p4: `add, edit, del, sync, reconcile` on `host.p4` |
|
||||
| `notify` | native notifications via `host.notify` |
|
||||
|
||||
Without a permission, the matching APIs are simply absent (or no-ops). `host.storage`,
|
||||
`host.clipboard`, `host.flash`, `host.info`, `host.t`, `host.theme` are always available.
|
||||
|
||||
## The `host` SDK
|
||||
```ts
|
||||
host.version // Host SDK version, e.g. "1.0"
|
||||
host.plugin // { id, name, dir }
|
||||
host.has(perm) // boolean
|
||||
|
||||
host.p4.<method>(...) // Promise — only the whitelisted set your perms allow
|
||||
host.info() // { userName, clientName, clientRoot } | null
|
||||
host.selectedFile() // { depotFile } | null
|
||||
host.refresh() // re-read the Changes view
|
||||
host.theme() // current theme's CSS-var overrides
|
||||
|
||||
host.ui.addMenuItem(label, run) // Actions-menu action
|
||||
host.ui.addFileContextItem(label, run(file)) // right-click a file
|
||||
host.ui.addView({ id, title, render(el, host) })// a panel; render into a DOM element (return an optional cleanup fn)
|
||||
host.ui.addSubmitHook(run(files) => {ok, message}) // block/allow a commit
|
||||
|
||||
host.flash(text, isError?) // toast
|
||||
host.notify(title, body) // native notification (needs "notify")
|
||||
host.clipboard.write(text) // Promise
|
||||
host.storage.get/set/remove(key) // per-plugin localStorage namespace
|
||||
host.t(key, params?) // app i18n
|
||||
host.log(...args) // console, tagged with the plugin id
|
||||
```
|
||||
|
||||
## Theme tokens
|
||||
A plugin's DOM inherits the app's CSS variables — use them so the panel follows the
|
||||
active theme. The exact names (a wrong name silently falls back to your hardcoded
|
||||
default, which then breaks on the opposite theme):
|
||||
|
||||
`--accent` `--accent-2` `--accent-deep` · `--bg` `--panel` `--panel-2` `--panel-3`
|
||||
`--elevated` `--border` · `--txt` `--muted` `--faint` · `--add` (success) `--edit`
|
||||
(warning) `--del` (danger) · `--mono` (font)
|
||||
|
||||
There is no `--text`, `--ok`, `--err`, or `--sans`.
|
||||
|
||||
## Contribution points
|
||||
- **Menu items** and **views** appear in the **Actions** menu.
|
||||
- **File context items** appear when you right-click a file in Changes.
|
||||
- **Submit hooks** run before a commit is finalised; returning `{ ok:false, message }`
|
||||
prompts the user to confirm or cancel. (This is exactly where a reference-integrity
|
||||
guard, size check, or lint would live.)
|
||||
|
||||
## Import methods
|
||||
1. **Install from folder** — Actions → Plugins… → *Install from folder…* → pick a folder
|
||||
that contains `plugin.json`. It's copied into the app's plugins folder.
|
||||
2. **Drop-in** — Actions → Plugins… → *Open plugins folder*, drop `my-plugin/` in there,
|
||||
then *Reload*.
|
||||
3. **(Planned) Registry** — a signed registry (`exbyte-depot-plugins` repo, minisign-signed
|
||||
bundles) for one-click install/updates, reusing the app's updater signing infra.
|
||||
|
||||
## Try the example
|
||||
`plugins-examples/hello-exbyte/` demonstrates all four contribution points plus storage,
|
||||
clipboard, info and notifications. Install it from folder, then:
|
||||
- Actions → **Say hello 👋** and Actions → **Workspace Notes**
|
||||
- right-click a file → **Copy depot path (plugin)**
|
||||
- try to commit a file named `wip*` → the submit guard warns.
|
||||
|
||||
## Safety
|
||||
Plugins run in-app on the main thread — install only ones you trust. The `id` is
|
||||
path-validated, the entry cannot escape the plugin folder, and each plugin's storage
|
||||
is namespaced. A plugin that throws is isolated and does not crash the app.
|
||||
10
RELEASING.md
@ -16,11 +16,19 @@ https://gitea.exbytestudios.com/ExbytePublicServices/exbyte-depot-viewer-perforc
|
||||
Без приватного ключа собрать валидное обновление невозможно — сделай бэкап ключа
|
||||
в надёжном месте (менеджер паролей / приватный репозиторий секретов).
|
||||
|
||||
## Ключ OpenRouter (AI-функции)
|
||||
|
||||
AI-ключ **вшивается в бинарник при компиляции** из файла `src-tauri/openrouter.key`
|
||||
(git-ignored, `*.key` в .gitignore). Без этого файла сборка упадёт с ошибкой
|
||||
`include_str!` — перед сборкой убедись, что файл на месте. Чтобы сменить ключ,
|
||||
замени содержимое файла и пересобери.
|
||||
|
||||
## Как выпустить новую версию
|
||||
|
||||
1. Подними версию в **двух** местах (должны совпадать):
|
||||
1. Подними версию в **трёх** местах (должны совпадать):
|
||||
- `package.json` → `"version"`
|
||||
- `src-tauri/tauri.conf.json` → `"version"`
|
||||
- `src-tauri/Cargo.toml` → `version`
|
||||
|
||||
2. Собери подписанный инсталлятор:
|
||||
```powershell
|
||||
|
||||
@ -2,9 +2,8 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tauri + React + Typescript</title>
|
||||
<title>Exbyte Depot</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
|
||||
876
landing/index.html
Normal file
@ -0,0 +1,876 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Exbyte Depot — Perforce for Unreal teams, finally pleasant</title>
|
||||
<meta name="description" content="Exbyte Depot is a fast native Perforce client built for Unreal Engine teams: 3D asset previews, one-click exclusive locks, offline work, and live teammate activity. All the power of Helix Core, none of the P4V pain." />
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0a0a0f; --bg-2:#0d0d14; --panel:#12121b; --panel-2:#16161f; --elevated:#1a1a24;
|
||||
--line:rgba(255,255,255,.08); --line-2:rgba(255,255,255,.05);
|
||||
--txt:#ececf4; --muted:#a2a2b4; --faint:#6d6d80;
|
||||
--acc:#7c6ef6; --acc-2:#9d93ff; --acc-deep:#5b4ee0;
|
||||
--ok:#3ecf8e; --warn:#e8b04b; --del:#f2637e;
|
||||
--maxw:1180px;
|
||||
--font:"Segoe UI Variable Display","Segoe UI",system-ui,-apple-system,Roboto,"Helvetica Neue",Arial,sans-serif;
|
||||
--mono:"Cascadia Code","JetBrains Mono",ui-monospace,"SF Mono",Consolas,monospace;
|
||||
--shadow:0 40px 120px -40px rgba(0,0,0,.8);
|
||||
}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
html{scroll-behavior:smooth}
|
||||
body{
|
||||
font-family:var(--font);background:var(--bg);color:var(--txt);
|
||||
-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility;
|
||||
line-height:1.6;overflow-x:hidden;
|
||||
}
|
||||
/* ambient background */
|
||||
body::before{
|
||||
content:"";position:fixed;inset:0;z-index:-2;pointer-events:none;
|
||||
background:
|
||||
radial-gradient(900px 640px at 78% -6%,rgba(124,110,246,.22),transparent 60%),
|
||||
radial-gradient(760px 620px at 6% 8%,rgba(91,78,224,.16),transparent 58%),
|
||||
var(--bg);
|
||||
}
|
||||
body::after{
|
||||
content:"";position:fixed;inset:0;z-index:-1;pointer-events:none;opacity:.5;
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,.020) 1px,transparent 1px),
|
||||
linear-gradient(90deg,rgba(255,255,255,.020) 1px,transparent 1px);
|
||||
background-size:46px 46px;
|
||||
-webkit-mask-image:radial-gradient(1200px 800px at 60% 0%,#000 30%,transparent 78%);
|
||||
mask-image:radial-gradient(1200px 800px at 60% 0%,#000 30%,transparent 78%);
|
||||
}
|
||||
a{color:inherit;text-decoration:none}
|
||||
.tlink{color:var(--acc-2);text-decoration:underline;text-underline-offset:2px;text-decoration-color:rgba(157,147,255,.45);transition:.15s}
|
||||
.tlink:hover{text-decoration-color:var(--acc-2)}
|
||||
/* "works with your Perforce server" reassurance band */
|
||||
.clarify{display:flex;align-items:center;justify-content:center;gap:12px;flex-wrap:wrap;text-align:center;
|
||||
max-width:760px;margin:26px auto 0;padding:14px 20px;border-radius:14px;
|
||||
background:var(--panel);border:1px solid var(--line);color:var(--muted);font-size:14px;line-height:1.55}
|
||||
.clarify svg{width:18px;height:18px;color:var(--acc-2);flex:0 0 auto}
|
||||
.clarify b{color:var(--txt);font-weight:600}
|
||||
.wrap{width:100%;max-width:var(--maxw);margin:0 auto;padding:0 24px}
|
||||
.eyebrow{display:inline-flex;align-items:center;gap:8px;font-size:12.5px;font-weight:600;letter-spacing:.08em;
|
||||
text-transform:uppercase;color:var(--acc-2);background:rgba(124,110,246,.10);border:1px solid rgba(124,110,246,.28);
|
||||
padding:6px 13px;border-radius:100px}
|
||||
.eyebrow .dot{width:6px;height:6px;border-radius:50%;background:var(--acc-2);box-shadow:0 0 10px var(--acc-2)}
|
||||
h1,h2,h3{line-height:1.08;letter-spacing:-.022em;font-weight:700;text-wrap:balance}
|
||||
.btn{display:inline-flex;align-items:center;justify-content:center;gap:9px;font-family:inherit;font-size:15px;
|
||||
font-weight:600;cursor:pointer;border-radius:12px;padding:13px 22px;border:1px solid transparent;transition:.18s ease;white-space:nowrap}
|
||||
.btn.primary{color:#fff;background:linear-gradient(135deg,var(--acc-2),var(--acc-deep));
|
||||
box-shadow:0 14px 34px -14px var(--acc),inset 0 1px 0 rgba(255,255,255,.22)}
|
||||
.btn.primary:hover{transform:translateY(-1px);box-shadow:0 20px 44px -14px var(--acc),inset 0 1px 0 rgba(255,255,255,.3)}
|
||||
.btn.ghost{color:var(--txt);background:rgba(255,255,255,.04);border-color:var(--line)}
|
||||
.btn.ghost:hover{background:rgba(255,255,255,.08);border-color:rgba(255,255,255,.18)}
|
||||
.btn svg{width:17px;height:17px}
|
||||
:focus-visible{outline:2px solid var(--acc-2);outline-offset:2px;border-radius:8px}
|
||||
|
||||
/* ---------- nav ---------- */
|
||||
header.nav{position:sticky;top:0;z-index:50;backdrop-filter:blur(14px);
|
||||
background:linear-gradient(180deg,rgba(10,10,15,.86),rgba(10,10,15,.55));border-bottom:1px solid var(--line-2)}
|
||||
.nav-in{display:flex;align-items:center;gap:20px;height:66px}
|
||||
.brand{display:flex;align-items:center;gap:11px;font-weight:700;font-size:16.5px;letter-spacing:-.01em}
|
||||
.brand .mark{width:34px;height:34px;border-radius:9px;display:grid;place-items:center;color:#fff;
|
||||
background:linear-gradient(140deg,var(--acc),var(--acc-deep));box-shadow:0 8px 22px -8px var(--acc),inset 0 1px 0 rgba(255,255,255,.35)}
|
||||
.brand .mark svg{width:19px;height:19px}
|
||||
.brand small{color:var(--faint);font-weight:500}
|
||||
.nav-links{display:flex;gap:26px;margin-left:18px}
|
||||
.nav-links a{font-size:14.5px;color:var(--muted);font-weight:500;transition:.15s}
|
||||
.nav-links a:hover{color:var(--txt)}
|
||||
.nav-cta{margin-left:auto;display:flex;align-items:center;gap:12px}
|
||||
|
||||
/* ---------- hero ---------- */
|
||||
.hero{padding:74px 0 40px;text-align:center}
|
||||
.hero h1{font-size:clamp(38px,6vw,68px);margin:22px auto 0;max-width:16ch}
|
||||
.hero h1 .g{background:linear-gradient(120deg,var(--acc-2),#c9c2ff 60%,var(--acc));-webkit-background-clip:text;background-clip:text;color:transparent}
|
||||
.hero p.sub{font-size:clamp(16px,2vw,19px);color:var(--muted);max-width:60ch;margin:22px auto 0;line-height:1.62}
|
||||
.waitlist{margin:34px auto 0;max-width:520px}
|
||||
.wl-form{display:flex;gap:10px;background:var(--panel);border:1px solid var(--line);border-radius:15px;padding:7px;
|
||||
box-shadow:var(--shadow)}
|
||||
.wl-form input{flex:1;min-width:0;background:transparent;border:none;outline:none;color:var(--txt);font-family:inherit;
|
||||
font-size:15px;padding:12px 14px}
|
||||
.wl-form input::placeholder{color:var(--faint)}
|
||||
.wl-note{margin-top:12px;font-size:13px;color:var(--faint);display:flex;align-items:center;justify-content:center;gap:8px;flex-wrap:wrap}
|
||||
.wl-note b{color:var(--muted);font-weight:600}
|
||||
.wl-ok{display:none;align-items:center;gap:12px;justify-content:center;background:rgba(62,207,142,.10);
|
||||
border:1px solid rgba(62,207,142,.32);color:#8ff0c4;border-radius:15px;padding:18px 20px;font-size:15px;box-shadow:var(--shadow)}
|
||||
.wl-ok.show{display:flex}
|
||||
.wl-ok svg{width:22px;height:22px;flex:0 0 auto}
|
||||
.avatars{display:flex;justify-content:center;margin-top:22px;align-items:center;gap:12px;color:var(--faint);font-size:13.5px}
|
||||
.stack{display:flex}
|
||||
.stack span{width:28px;height:28px;border-radius:50%;border:2px solid var(--bg);margin-left:-8px;
|
||||
display:grid;place-items:center;font-size:11px;font-weight:700;color:#fff}
|
||||
|
||||
/* ---------- app mock ---------- */
|
||||
.mock-shell{margin:56px 0 0;width:min(1280px,95vw);max-width:1280px;perspective:1800px;
|
||||
position:relative;left:50%;transform:translateX(-50%)}
|
||||
.mock{border-radius:16px;overflow:hidden;border:1px solid var(--line);background:var(--panel);
|
||||
box-shadow:var(--shadow),0 0 0 1px rgba(124,110,246,.10);transform:rotateX(2.5deg);transform-origin:center top}
|
||||
.m-title{display:flex;align-items:center;gap:12px;height:46px;padding:0 14px;background:var(--panel-2);border-bottom:1px solid var(--line)}
|
||||
.m-title .mark{width:22px;height:22px;border-radius:6px;display:grid;place-items:center;color:#fff;background:linear-gradient(140deg,var(--acc),var(--acc-deep))}
|
||||
.m-title .mark svg{width:13px;height:13px}
|
||||
.m-title .t{font-size:13px;font-weight:600}.m-title .t span{color:var(--faint);font-weight:500}
|
||||
.m-menu{display:flex;gap:16px;margin-left:10px;font-size:12.5px;color:var(--muted)}
|
||||
.m-pill{margin-left:auto;display:flex;align-items:center;gap:7px;font-size:11.5px;color:var(--muted);
|
||||
background:rgba(62,207,142,.10);border:1px solid rgba(62,207,142,.28);padding:4px 11px;border-radius:100px}
|
||||
.m-pill .d{width:6px;height:6px;border-radius:50%;background:var(--ok);box-shadow:0 0 8px var(--ok)}
|
||||
.m-body{display:grid;grid-template-columns:340px 1fr;min-height:376px}
|
||||
.m-left{border-right:1px solid var(--line);display:flex;flex-direction:column}
|
||||
.m-tabs{display:flex;gap:20px;padding:14px 16px 0;font-size:13px;color:var(--faint)}
|
||||
.m-tabs .on{color:var(--txt);font-weight:600;position:relative;padding-bottom:12px}
|
||||
.m-tabs .on::after{content:"";position:absolute;left:0;right:0;bottom:0;height:2px;background:var(--acc);border-radius:2px}
|
||||
.m-tabs .badge{background:rgba(255,255,255,.08);color:var(--muted);border-radius:8px;padding:0 7px;font-size:11px;margin-left:5px}
|
||||
.m-list{padding:12px 12px 0;display:flex;flex-direction:column;gap:7px}
|
||||
.m-file{display:flex;align-items:center;gap:10px;padding:8px 9px;border-radius:10px}
|
||||
.m-file:hover{background:rgba(255,255,255,.03)}
|
||||
.m-file.sel{background:rgba(124,110,246,.12);box-shadow:inset 0 0 0 1px rgba(124,110,246,.25)}
|
||||
.m-chk{width:16px;height:16px;border-radius:5px;background:linear-gradient(135deg,var(--acc-2),var(--acc-deep));display:grid;place-items:center;flex:0 0 auto}
|
||||
.m-chk svg{width:10px;height:10px;color:#fff}
|
||||
.m-ico{width:26px;height:26px;border-radius:7px;background:var(--panel-2);border:1px solid var(--line);display:grid;place-items:center;color:var(--acc-2);flex:0 0 auto}
|
||||
.m-ico svg{width:14px;height:14px}
|
||||
.m-fmeta{min-width:0}
|
||||
.m-fmeta .n{font-size:12.5px;font-weight:600;white-space:nowrap}
|
||||
.m-fmeta .p{font-size:10.5px;color:var(--faint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:180px}
|
||||
.m-plus{margin-left:auto;color:var(--ok)}
|
||||
.m-commit{margin-top:auto;padding:14px;border-top:1px solid var(--line);display:flex;flex-direction:column;gap:9px}
|
||||
.m-input{background:var(--panel-2);border:1px solid var(--line);border-radius:9px;padding:9px 11px;font-size:12px;color:var(--muted)}
|
||||
.m-submit{background:linear-gradient(135deg,var(--acc-2),var(--acc-deep));color:#fff;border-radius:10px;padding:11px;
|
||||
font-size:12.5px;font-weight:600;text-align:center;display:flex;align-items:center;justify-content:center;gap:7px}
|
||||
.m-stage{position:relative;display:grid;place-items:center;
|
||||
background:
|
||||
radial-gradient(520px 380px at 50% 38%,rgba(124,110,246,.12),transparent 62%),
|
||||
repeating-linear-gradient(0deg,transparent,transparent 31px,rgba(255,255,255,.02) 31px,rgba(255,255,255,.02) 32px),
|
||||
repeating-linear-gradient(90deg,transparent,transparent 31px,rgba(255,255,255,.02) 31px,rgba(255,255,255,.02) 32px),
|
||||
var(--bg-2)}
|
||||
.m-stage .lab{position:absolute;top:14px;left:14px;font-size:11px;color:var(--muted);background:rgba(0,0,0,.4);
|
||||
border:1px solid var(--line);border-radius:100px;padding:5px 11px;display:flex;align-items:center;gap:7px}
|
||||
.m-cube{width:120px;height:120px;color:var(--acc-2);filter:drop-shadow(0 20px 40px rgba(124,110,246,.4));animation:float 5s ease-in-out infinite}
|
||||
@keyframes float{0%,100%{transform:translateY(0) rotate(-4deg)}50%{transform:translateY(-12px) rotate(4deg)}}
|
||||
.m-load{position:absolute;bottom:56px;left:50%;transform:translateX(-50%);
|
||||
background:linear-gradient(135deg,var(--acc-2),var(--acc-deep));color:#fff;border-radius:10px;padding:10px 18px;
|
||||
font-size:12.5px;font-weight:600;display:flex;align-items:center;gap:8px;box-shadow:0 14px 30px -12px var(--acc)}
|
||||
/* titlebar extras */
|
||||
.m-title .m-actions{display:flex;align-items:center;gap:6px;margin-left:14px}
|
||||
.m-ib{width:26px;height:26px;border-radius:7px;display:grid;place-items:center;color:var(--muted);background:rgba(255,255,255,.04);border:1px solid var(--line-2)}
|
||||
.m-ib svg{width:14px;height:14px}
|
||||
.m-wc{display:flex;gap:9px;margin-left:12px}
|
||||
.m-wc i{width:12px;height:12px;border-radius:50%;display:block}
|
||||
.m-wc .r{background:#ff5f57}.m-wc .y{background:#febc2e}.m-wc .g{background:#28c840}
|
||||
/* second header row: workspace / working folder / get latest */
|
||||
.m-head{display:grid;grid-template-columns:1fr 1fr 190px;border-bottom:1px solid var(--line)}
|
||||
.m-zone{display:flex;align-items:center;gap:11px;padding:12px 16px;border-right:1px solid var(--line);min-width:0}
|
||||
.m-zone .zi{width:30px;height:30px;border-radius:8px;display:grid;place-items:center;color:var(--acc-2);background:rgba(124,110,246,.10);border:1px solid rgba(124,110,246,.2);flex:0 0 auto}
|
||||
.m-zone .zi svg{width:16px;height:16px}
|
||||
.m-zone .zm{min-width:0}
|
||||
.m-zone .zm .l{font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--faint)}
|
||||
.m-zone .zm .v{font-size:13px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.m-zone .cv{margin-left:auto;color:var(--faint);flex:0 0 auto}
|
||||
.m-zone.get{border-right:none;cursor:default}
|
||||
.m-zone.get .zi{color:var(--ok);background:rgba(62,207,142,.10);border-color:rgba(62,207,142,.22)}
|
||||
.m-zone.get .v{color:var(--txt)}.m-zone.get .l{color:var(--faint)}
|
||||
/* left column bits */
|
||||
.m-filter{margin:12px 12px 4px;display:flex;align-items:center;gap:8px;background:var(--panel-2);border:1px solid var(--line);border-radius:9px;padding:8px 11px;color:var(--faint);font-size:12px}
|
||||
.m-filter svg{width:14px;height:14px}
|
||||
.m-selhead{display:flex;align-items:center;gap:9px;padding:8px 16px 6px;font-size:11.5px;color:var(--muted)}
|
||||
.m-selhead .sc{width:15px;height:15px;border-radius:5px;background:linear-gradient(135deg,var(--acc-2),var(--acc-deep));display:grid;place-items:center;flex:0 0 auto}
|
||||
.m-selhead .sc svg{width:9px;height:9px;color:#fff}
|
||||
.m-selhead .rf{margin-left:auto;display:flex;align-items:center;gap:5px;color:var(--muted);background:rgba(255,255,255,.04);border:1px solid var(--line);border-radius:7px;padding:4px 9px;font-size:11px}
|
||||
.m-selhead .rf svg{width:12px;height:12px}
|
||||
.m-stat{width:18px;height:18px;border-radius:5px;display:grid;place-items:center;font-size:10px;font-weight:700;flex:0 0 auto}
|
||||
.m-stat.a{color:var(--ok);background:rgba(62,207,142,.14)}
|
||||
.m-plus{margin-left:auto}
|
||||
.ai-btn{width:34px;height:34px;flex:0 0 auto;border-radius:9px;display:grid;place-items:center;color:var(--acc-2);background:rgba(124,110,246,.10);border:1px solid rgba(124,110,246,.24)}
|
||||
.ai-btn svg{width:16px;height:16px}
|
||||
.m-summrow{display:flex;gap:8px;align-items:stretch}
|
||||
.m-summrow .m-input{flex:1}
|
||||
/* right: diff head + meta bar */
|
||||
.m-right{display:flex;flex-direction:column;min-width:0}
|
||||
.m-diffhead{display:flex;align-items:center;gap:11px;padding:12px 16px;border-bottom:1px solid var(--line)}
|
||||
.m-diffhead .ttl{min-width:0}
|
||||
.m-diffhead .ttl .n{font-size:13.5px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.m-diffhead .ttl .n span{color:var(--faint);font-weight:500}
|
||||
.m-diffhead .ttl .mm{font-size:11px;color:var(--faint)}
|
||||
.m-diffhead .mrev{color:var(--acc-2)}
|
||||
.m-revert{margin-left:auto;display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted);background:rgba(255,255,255,.04);border:1px solid var(--line);border-radius:8px;padding:6px 12px;flex:0 0 auto}
|
||||
.m-revert svg{width:13px;height:13px}
|
||||
.m-stage{flex:1;position:relative}
|
||||
.m-metabar{border-top:1px solid var(--line);padding:12px 18px;display:grid;grid-template-columns:repeat(4,1fr);gap:10px;background:rgba(0,0,0,.16)}
|
||||
.m-metabar .mi .k{font-size:9.5px;letter-spacing:.08em;text-transform:uppercase;color:var(--faint)}
|
||||
.m-metabar .mi .v{font-size:13px;font-weight:600;margin-top:3px}
|
||||
.m-metabar .mi .v.acc{color:var(--acc-2)}
|
||||
/* bottom status bar */
|
||||
.m-status{display:flex;align-items:center;gap:9px;height:34px;padding:0 14px;border-top:1px solid var(--line);background:var(--panel-2);font-size:11px;color:var(--faint)}
|
||||
.m-status .cmd{font-family:var(--mono);color:var(--muted)}
|
||||
.m-status .ok{color:var(--ok)}
|
||||
.m-status .chips{margin-left:auto;display:flex;gap:7px}
|
||||
.m-status .chips b{display:flex;align-items:center;gap:5px;font-weight:500;color:var(--muted);background:rgba(255,255,255,.04);border:1px solid var(--line-2);border-radius:7px;padding:3px 9px}
|
||||
.m-status .chips b svg{width:12px;height:12px}
|
||||
@media (max-width:900px){
|
||||
.m-head{grid-template-columns:1fr}
|
||||
.m-zone{border-right:none;border-bottom:1px solid var(--line)}
|
||||
.m-zone.get{display:none}
|
||||
.m-metabar{grid-template-columns:1fr 1fr}
|
||||
}
|
||||
/* ---- interactive mock: hover / active / press ---- */
|
||||
.m-menu span,.m-file,.m-tabs span,.m-submit,.m-load,.m-zone.get,.m-ib,.m-selhead .rf,
|
||||
.m-revert,.m-chk,.ai-btn,.m-status .chips b,.m-wc i{cursor:pointer}
|
||||
.m-menu span{padding:3px 6px;border-radius:6px;transition:.14s}
|
||||
.m-menu span:hover{background:rgba(255,255,255,.06);color:var(--txt)}
|
||||
.m-tabs span:hover{color:var(--txt)}
|
||||
.m-ib{transition:.14s}.m-ib:hover{color:var(--txt);background:rgba(255,255,255,.08);border-color:rgba(255,255,255,.16)}
|
||||
.m-wc i{transition:.14s}.m-wc i:hover{filter:brightness(1.25)}
|
||||
.m-file{transition:background .14s,box-shadow .14s;user-select:none}
|
||||
.m-file:active{transform:translateY(.5px)}
|
||||
.m-chk{transition:.14s}
|
||||
.m-file.off .m-chk{background:transparent;border:1.5px solid var(--faint);box-shadow:none}
|
||||
.m-file.off .m-chk svg{display:none}
|
||||
.m-selhead .rf,.m-revert{transition:.14s}
|
||||
.m-selhead .rf:hover,.m-revert:hover{color:var(--txt);border-color:rgba(255,255,255,.2);background:rgba(255,255,255,.07)}
|
||||
.m-selhead .rf.spin svg{animation:mspin .7s linear infinite}
|
||||
.ai-btn{transition:.14s}.ai-btn:hover{background:rgba(124,110,246,.18);border-color:rgba(124,110,246,.4)}
|
||||
.m-zone.get{transition:.14s;border-radius:0}.m-zone.get:hover{background:rgba(62,207,142,.06)}
|
||||
.m-zone.get.spin .zi svg{animation:mspin .7s linear infinite}
|
||||
.m-submit,.m-load{transition:transform .12s,box-shadow .14s,filter .14s}
|
||||
.m-submit:hover,.m-load:hover{filter:brightness(1.08)}
|
||||
.m-submit:active,.m-load:active{transform:scale(.98)}
|
||||
.m-load:hover{transform:translateX(-50%) scale(1.03)}
|
||||
.m-load:active{transform:translateX(-50%) scale(.97)}
|
||||
.m-status .chips b{transition:.14s}.m-status .chips b:hover{color:var(--txt);border-color:var(--line)}
|
||||
@keyframes mspin{to{transform:rotate(360deg)}}
|
||||
.m-spinner{width:14px;height:14px;border:2px solid rgba(255,255,255,.4);border-top-color:#fff;border-radius:50%;
|
||||
animation:mspin .7s linear infinite;display:inline-block}
|
||||
/* preview variants */
|
||||
.pv{position:absolute;inset:0;display:grid;place-items:center}
|
||||
.pv[hidden]{display:none}
|
||||
.m-cube.loaded{animation:mrotate 7s linear infinite}
|
||||
@keyframes mrotate{to{transform:rotateY(360deg)}}
|
||||
.pv-mesh{perspective:900px}
|
||||
.tex-swatch{width:min(62%,300px);aspect-ratio:1;border-radius:14px;box-shadow:0 24px 60px -24px #000;border:1px solid var(--line);
|
||||
background:
|
||||
repeating-conic-gradient(from 45deg,#2a2733 0deg 90deg,#211f2a 90deg 180deg) 0 0/26px 26px,
|
||||
radial-gradient(120% 120% at 30% 20%,rgba(124,110,246,.25),transparent 60%)}
|
||||
.tex-cap{position:absolute;bottom:20px;left:50%;transform:translateX(-50%);font-size:11px;color:var(--muted);
|
||||
background:rgba(0,0,0,.4);border:1px solid var(--line);border-radius:100px;padding:5px 12px}
|
||||
.pv-code{align-items:stretch;justify-items:stretch;padding:0}
|
||||
.codeblk{width:100%;height:100%;overflow:hidden;font-family:var(--mono);font-size:12px;line-height:1.75;
|
||||
padding:16px 18px;color:#c7c7d6;text-align:left;background:var(--bg-2)}
|
||||
.codeblk .ln{display:block;white-space:pre}
|
||||
.codeblk .kw{color:#9d93ff}.codeblk .ty{color:#5cc9c0}.codeblk .fn{color:#e8b04b}.codeblk .cm{color:#5f5f72;font-style:italic}.codeblk .st{color:#8ff0c4}
|
||||
/* only one body panel visible at a time (beats inline/class display) */
|
||||
.m-panel[hidden]{display:none!important}
|
||||
/* history list (History tab) */
|
||||
.m-hist{padding:10px 12px;display:flex;flex-direction:column;gap:4px;overflow:auto}
|
||||
.m-hrow{display:flex;align-items:center;gap:11px;padding:9px 10px;border-radius:10px;cursor:pointer;transition:.14s}
|
||||
.m-hrow:hover{background:rgba(255,255,255,.04)}
|
||||
.m-hav{width:26px;height:26px;border-radius:50%;flex:0 0 auto;display:grid;place-items:center;font-size:10px;font-weight:700;color:#fff}
|
||||
.m-hb{min-width:0;flex:1}
|
||||
.m-hb .d{font-size:12.5px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.m-hb .mt{font-size:10.5px;color:var(--faint)}
|
||||
.m-hsz{font-size:10.5px;font-weight:600;color:var(--muted);background:var(--panel-2);border:1px solid var(--line);border-radius:6px;padding:1px 7px;white-space:nowrap}
|
||||
|
||||
/* ---------- logos / trust ---------- */
|
||||
.trust{padding:44px 0 8px;text-align:center}
|
||||
.trust p{font-size:12.5px;letter-spacing:.14em;text-transform:uppercase;color:var(--faint);margin-bottom:20px}
|
||||
.trust-row{display:flex;flex-wrap:wrap;justify-content:center;gap:14px}
|
||||
.chip{display:inline-flex;align-items:center;gap:8px;font-size:13.5px;color:var(--muted);font-weight:500;
|
||||
background:var(--panel);border:1px solid var(--line);border-radius:100px;padding:9px 15px}
|
||||
.chip svg{width:15px;height:15px;color:var(--acc-2)}
|
||||
|
||||
/* ---------- section headers ---------- */
|
||||
section{padding:86px 0}
|
||||
.sec-head{max-width:720px;margin:0 auto 46px;text-align:center}
|
||||
.sec-head h2{font-size:clamp(28px,4vw,42px)}
|
||||
.sec-head p{color:var(--muted);font-size:17px;margin-top:14px;line-height:1.6}
|
||||
|
||||
/* ---------- features grid ---------- */
|
||||
.grid{display:grid;grid-template-columns:repeat(3,1fr);gap:18px}
|
||||
.card{background:linear-gradient(180deg,var(--panel),var(--bg-2));border:1px solid var(--line);border-radius:16px;
|
||||
padding:26px 24px;transition:.2s ease;position:relative;overflow:hidden}
|
||||
.card:hover{border-color:rgba(124,110,246,.4);transform:translateY(-3px)}
|
||||
.card:hover::before{opacity:1}
|
||||
.card::before{content:"";position:absolute;inset:0;opacity:0;transition:.25s;pointer-events:none;
|
||||
background:radial-gradient(340px 180px at 20% 0%,rgba(124,110,246,.12),transparent 70%)}
|
||||
.card .ic{width:44px;height:44px;border-radius:12px;display:grid;place-items:center;color:var(--acc-2);
|
||||
background:rgba(124,110,246,.10);border:1px solid rgba(124,110,246,.24);margin-bottom:18px}
|
||||
.card .ic svg{width:22px;height:22px}
|
||||
.card h3{font-size:18px;letter-spacing:-.01em}
|
||||
.card p{color:var(--muted);font-size:14.5px;margin-top:9px;line-height:1.6}
|
||||
.card .tag{position:absolute;top:20px;right:20px;font-size:10px;font-weight:700;letter-spacing:.06em;text-transform:uppercase;
|
||||
color:var(--acc-2);background:rgba(124,110,246,.12);border:1px solid rgba(124,110,246,.28);border-radius:7px;padding:3px 7px}
|
||||
|
||||
/* ---------- spotlight (why not p4v) ---------- */
|
||||
.split{display:grid;grid-template-columns:1fr 1fr;gap:56px;align-items:center}
|
||||
.split h2{font-size:clamp(26px,3.4vw,38px)}
|
||||
.split .lead{color:var(--muted);font-size:16.5px;margin-top:16px}
|
||||
.comp{display:flex;flex-direction:column;gap:10px;margin-top:26px}
|
||||
.crow{display:flex;align-items:flex-start;gap:13px;padding:14px 16px;border-radius:13px;background:var(--panel);border:1px solid var(--line)}
|
||||
.crow .ck{width:22px;height:22px;border-radius:7px;display:grid;place-items:center;flex:0 0 auto;margin-top:1px}
|
||||
.crow.good .ck{background:rgba(62,207,142,.14);color:var(--ok)}
|
||||
.crow .ck svg{width:13px;height:13px}
|
||||
.crow b{font-size:14.5px}.crow span{color:var(--muted);font-size:13.5px;display:block;margin-top:2px}
|
||||
.panel-visual{background:linear-gradient(180deg,var(--panel),var(--bg-2));border:1px solid var(--line);border-radius:18px;
|
||||
padding:22px;box-shadow:var(--shadow)}
|
||||
.lock-item{display:flex;align-items:center;gap:12px;padding:12px;border-radius:11px;background:var(--panel-2);border:1px solid var(--line);margin-bottom:9px}
|
||||
.lock-item:last-child{margin-bottom:0}
|
||||
.lock-badge{width:30px;height:30px;border-radius:8px;display:grid;place-items:center;flex:0 0 auto}
|
||||
.lock-badge.red{background:rgba(242,99,126,.12);color:var(--del)}
|
||||
.lock-badge.gr{background:rgba(62,207,142,.12);color:var(--ok)}
|
||||
.lock-badge svg{width:15px;height:15px}
|
||||
.lock-item .li-b{min-width:0}
|
||||
.lock-item .li-b .n{font-size:13.5px;font-weight:600}
|
||||
.lock-item .li-b .p{font-size:11.5px;color:var(--faint)}
|
||||
.lock-item .who{margin-left:auto;font-size:11.5px;color:var(--muted);white-space:nowrap}
|
||||
|
||||
/* ---------- tech strip ---------- */
|
||||
.tech{display:grid;grid-template-columns:repeat(4,1fr);gap:18px;text-align:center}
|
||||
.tech .t{padding:24px 16px;border-radius:14px;background:var(--panel);border:1px solid var(--line)}
|
||||
.tech .t .k{font-size:30px;font-weight:800;letter-spacing:-.02em;
|
||||
background:linear-gradient(120deg,var(--acc-2),#cfc9ff);-webkit-background-clip:text;background-clip:text;color:transparent}
|
||||
.tech .t .v{color:var(--muted);font-size:13px;margin-top:6px}
|
||||
|
||||
/* ---------- final CTA ---------- */
|
||||
.cta{position:relative;text-align:center;border-radius:26px;padding:64px 28px;overflow:hidden;
|
||||
background:linear-gradient(180deg,rgba(124,110,246,.14),rgba(91,78,224,.05));border:1px solid rgba(124,110,246,.28)}
|
||||
.cta::before{content:"";position:absolute;inset:0;z-index:-1;
|
||||
background:radial-gradient(600px 300px at 50% 0%,rgba(124,110,246,.25),transparent 70%)}
|
||||
.cta h2{font-size:clamp(28px,4vw,44px)}
|
||||
.cta p{color:var(--muted);font-size:17px;margin:16px auto 30px;max-width:52ch}
|
||||
.cta .waitlist{margin-top:0}
|
||||
|
||||
/* ---------- footer ---------- */
|
||||
footer{border-top:1px solid var(--line-2);padding:40px 0;margin-top:20px}
|
||||
.foot{display:flex;align-items:center;gap:16px;flex-wrap:wrap;color:var(--faint);font-size:13.5px}
|
||||
.foot .brand{font-size:15px}
|
||||
.foot .fl{margin-left:auto;display:flex;gap:22px}
|
||||
.foot a:hover{color:var(--muted)}
|
||||
|
||||
@media (max-width:900px){
|
||||
.nav-links{display:none}
|
||||
.m-body{grid-template-columns:1fr}
|
||||
.m-left{border-right:none;border-bottom:1px solid var(--line)}
|
||||
.m-stage{min-height:260px}
|
||||
.grid{grid-template-columns:1fr 1fr}
|
||||
.split{grid-template-columns:1fr;gap:34px}
|
||||
.tech{grid-template-columns:1fr 1fr}
|
||||
}
|
||||
@media (max-width:560px){
|
||||
.grid{grid-template-columns:1fr}
|
||||
.wl-form{flex-direction:column}
|
||||
.wl-form .btn{width:100%}
|
||||
.hero{padding:48px 0 30px}
|
||||
.mock{transform:none}
|
||||
}
|
||||
@media (prefers-reduced-motion:reduce){*{animation:none!important;transition:none!important}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- ===================== NAV ===================== -->
|
||||
<header class="nav">
|
||||
<div class="wrap nav-in">
|
||||
<a class="brand" href="#top">
|
||||
<span class="mark"><svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/><path d="m3 7 9 5 9-5M12 12v10" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg></span>
|
||||
Exbyte Depot <small>— Perforce</small>
|
||||
</a>
|
||||
<nav class="nav-links">
|
||||
<a href="#features">Features</a>
|
||||
<a href="#why">Why not P4V</a>
|
||||
<a href="#tech">Under the hood</a>
|
||||
</nav>
|
||||
<div class="nav-cta">
|
||||
<a class="btn ghost" href="#waitlist">Join the waitlist</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<a id="top"></a>
|
||||
<!-- ===================== HERO ===================== -->
|
||||
<section class="hero">
|
||||
<div class="wrap">
|
||||
<span class="eyebrow"><span class="dot"></span> A desktop client for Perforce® · by Exbyte Studios</span>
|
||||
<h1>The <a class="g" href="https://www.perforce.com/products/helix-core" target="_blank" rel="noopener">Perforce</a> client your <span class="g">Unreal team</span> actually wants.</h1>
|
||||
<p class="sub">Exbyte Depot is a fast, native desktop <b style="color:var(--txt)">client for <a class="tlink" href="https://www.perforce.com/products/helix-core" target="_blank" rel="noopener">Perforce Helix Core</a></b> — real 3D asset previews, one-click exclusive locks, offline work, and live teammate activity. All the power of your Perforce server, none of the P4V pain.</p>
|
||||
|
||||
<div class="clarify">
|
||||
<svg viewBox="0 0 24 24" fill="none"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="m9 12 2 2 4-4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<span><b>It's a client, not a replacement.</b> Exbyte Depot connects to your existing <a class="tlink" href="https://www.perforce.com/" target="_blank" rel="noopener">Perforce</a> server through Perforce's own <span style="font-family:var(--mono);font-size:13px">p4</span> command-line — your server, history and workflow stay exactly as they are. We don't clone Perforce or reverse-engineer anything; we just give you a nicer window into it.</span>
|
||||
</div>
|
||||
|
||||
<div class="waitlist" id="waitlist">
|
||||
<form class="wl-form" id="wlForm" novalidate>
|
||||
<input id="wlEmail" type="email" inputmode="email" autocomplete="email" placeholder="you@studio.com" aria-label="Email address" required />
|
||||
<button class="btn primary" type="submit">
|
||||
Join the waitlist
|
||||
<svg viewBox="0 0 24 24" fill="none"><path d="M5 12h14m-6-6 6 6-6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
<div class="wl-ok" id="wlOk">
|
||||
<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.7"/><path d="m8.5 12 2.5 2.5 4.5-5" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<span id="wlOkMsg">You're on the list. We'll email you the moment early access opens.</span>
|
||||
</div>
|
||||
<div class="wl-note"><b>Windows</b> · early access · no spam, one launch email · <b id="wlCount">—</b> on the list</div>
|
||||
</div>
|
||||
|
||||
<!-- ============ APP MOCK ============ -->
|
||||
<div class="mock-shell">
|
||||
<div class="mock">
|
||||
<!-- titlebar -->
|
||||
<div class="m-title">
|
||||
<span class="mark"><svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg></span>
|
||||
<span class="t">Exbyte Depot <span>— Perforce</span></span>
|
||||
<span class="m-menu"><span>File</span><span>Connection</span><span>Actions</span><span>Window</span><span>Tools</span><span>Help</span></span>
|
||||
<span class="m-pill"><span class="d"></span> helix.auroragames.dev:1666</span>
|
||||
<span class="m-actions">
|
||||
<span class="m-ib"><svg viewBox="0 0 24 24" fill="none"><path d="M16 20v-1a4 4 0 0 0-4-4H7a4 4 0 0 0-4 4v1M9.5 11a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Zm11 9v-1a4 4 0 0 0-3-3.9M16 4.1a4 4 0 0 1 0 7.8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></span>
|
||||
<span class="m-ib"><svg viewBox="0 0 24 24" fill="none"><path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg></span>
|
||||
</span>
|
||||
<span class="m-wc"><i class="g"></i><i class="y"></i><i class="r"></i></span>
|
||||
</div>
|
||||
|
||||
<!-- workspace / working folder / get latest -->
|
||||
<div class="m-head">
|
||||
<div class="m-zone">
|
||||
<span class="zi"><svg viewBox="0 0 24 24" fill="none"><rect x="3" y="4" width="18" height="13" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M8 20h8m-4-3v3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></span>
|
||||
<span class="zm"><span class="l">Workspace</span><span class="v">kestrel_WS_02</span></span>
|
||||
<span class="cv"><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="m6 9 6 6 6-6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
|
||||
</div>
|
||||
<div class="m-zone">
|
||||
<span class="zi"><svg viewBox="0 0 24 24" fill="none"><path d="M6 3v12a3 3 0 0 0 3 3h6M6 3a3 3 0 1 0 0 6M6 3a3 3 0 1 1 0 6m9 9a3 3 0 1 0 6 0 3 3 0 0 0-6 0Z" stroke="currentColor" stroke-width="1.6"/></svg></span>
|
||||
<span class="zm"><span class="l">Working folder</span><span class="v">//depot/Aurora_Road</span></span>
|
||||
<span class="cv"><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="m6 9 6 6 6-6" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
|
||||
</div>
|
||||
<div class="m-zone get">
|
||||
<span class="zi"><svg viewBox="0 0 24 24" fill="none"><path d="M21 12a9 9 0 1 1-3-6.7M21 4v4h-4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
|
||||
<span class="zm"><span class="v">Get Latest</span><span class="l">pull from server</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- body -->
|
||||
<div class="m-body">
|
||||
<div class="m-left">
|
||||
<div class="m-tabs"><span class="on" data-tab="changes">Changes <span class="badge">3</span></span><span data-tab="history">History</span></div>
|
||||
|
||||
<!-- CHANGES -->
|
||||
<div class="m-panel" data-panel="changes" style="display:flex;flex-direction:column;flex:1;min-height:0">
|
||||
<div class="m-filter"><svg viewBox="0 0 24 24" fill="none"><circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="1.7"/><path d="m20 20-3.5-3.5" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg> Filter 3 files…</div>
|
||||
<div class="m-selhead">
|
||||
<span class="sc"><svg viewBox="0 0 24 24" fill="none"><path d="m5 12 4 4 10-10" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
|
||||
<span id="mSelTxt">3 of 3 selected</span>
|
||||
<span class="rf" id="mRefresh"><svg viewBox="0 0 24 24" fill="none"><path d="M21 12a9 9 0 1 1-3-6.7M21 4v4h-4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg> Refresh</span>
|
||||
</div>
|
||||
<div class="m-list" id="mList" style="flex:1;min-height:0;overflow:auto">
|
||||
<div class="m-file sel" data-name="SM_Guardrail.uasset" data-dir="— //depot/Aurora_Road/Content/Meshes/" data-meta="add · #1 · binary" data-kind="mesh" data-type="binary +l" data-prev="3D mesh" data-lab="3D preview">
|
||||
<span class="m-chk"><svg viewBox="0 0 24 24" fill="none"><path d="m5 12 4 4 10-10" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
|
||||
<span class="m-ico"><svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg></span>
|
||||
<span class="m-fmeta"><span class="n">SM_Guardrail.uasset</span><span class="p">//depot/…/Content/Meshes/</span></span>
|
||||
<span class="m-stat a">+</span>
|
||||
</div>
|
||||
<div class="m-file" data-name="T_Asphalt_D.uasset" data-dir="— //depot/Aurora_Road/Content/Textures/" data-meta="add · #1 · binary" data-kind="tex" data-type="binary" data-prev="2048 × 2048" data-lab="Image">
|
||||
<span class="m-chk"><svg viewBox="0 0 24 24" fill="none"><path d="m5 12 4 4 10-10" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
|
||||
<span class="m-ico"><svg viewBox="0 0 24 24" fill="none"><rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="m3 16 5-5 4 4 3-3 6 6" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg></span>
|
||||
<span class="m-fmeta"><span class="n">T_Asphalt_D.uasset</span><span class="p">//depot/…/Content/Textures/</span></span>
|
||||
<span class="m-stat a">+</span>
|
||||
</div>
|
||||
<div class="m-file" data-name="RoadSampler.cpp" data-dir="— //depot/Aurora_Road/Source/Aurora/" data-meta="edit · #7 → #8 · text" data-kind="code" data-type="text · C++" data-prev="312 lines" data-lab="Source">
|
||||
<span class="m-chk"><svg viewBox="0 0 24 24" fill="none"><path d="m5 12 4 4 10-10" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span>
|
||||
<span class="m-ico"><svg viewBox="0 0 24 24" fill="none"><path d="M8 3H5a2 2 0 0 0-2 2v3m0 8v3a2 2 0 0 0 2 2h3m8-18h3a2 2 0 0 1 2 2v3m0 8v3a2 2 0 0 1-2 2h-3M9 9l6 6m0-6-6 6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></span>
|
||||
<span class="m-fmeta"><span class="n">RoadSampler.cpp</span><span class="p">//depot/…/Source/Aurora/</span></span>
|
||||
<span class="m-stat a">+</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="m-commit">
|
||||
<div class="m-summrow">
|
||||
<div class="m-input">Add guardrail mesh + asphalt texture for road kit</div>
|
||||
<div class="ai-btn" title="Draft with AI"><svg viewBox="0 0 24 24" fill="none"><path d="M12 3v3m0 12v3M5 12H2m20 0h-3M6 6l2 2m8 8 2 2m0-12-2 2M8 16l-2 2" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><circle cx="12" cy="12" r="3" stroke="currentColor" stroke-width="1.6"/></svg></div>
|
||||
</div>
|
||||
<div class="m-submit" id="mCommit"><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="m5 12 4 4 10-10" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg> <span id="mCommitTxt">Commit changelist · 3 files</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- HISTORY -->
|
||||
<div class="m-panel m-hist" data-panel="history" hidden>
|
||||
<div class="m-hrow"><span class="m-hav" style="background:#7c6ef6">VD</span><span class="m-hb"><span class="d">road guardrails + collision</span><span class="mt">#34 · voss_dev · 2h ago</span></span><span class="m-hsz">146 KB</span></div>
|
||||
<div class="m-hrow"><span class="m-hav" style="background:#e8b04b">MA</span><span class="m-hb"><span class="d">asphalt material pass</span><span class="mt">#33 · mira_art · 5h ago</span></span><span class="m-hsz">172 KB</span></div>
|
||||
<div class="m-hrow"><span class="m-hav" style="background:#3ecf8e">NK</span><span class="m-hb"><span class="d">early car handling setup</span><span class="mt">#31 · novak_dev · yesterday</span></span><span class="m-hsz">21.3 KB</span></div>
|
||||
<div class="m-hrow"><span class="m-hav" style="background:#f2637e">KS</span><span class="m-hb"><span class="d">new road master material</span><span class="mt">#28 · kestrel · 2 days ago</span></span><span class="m-hsz">6.4 KB</span></div>
|
||||
<div class="m-hrow"><span class="m-hav" style="background:#5cc9c0">KS</span><span class="m-hb"><span class="d">Project Init</span><span class="mt">#18 · kestrel · last week</span></span><span class="m-hsz">34.1 GB</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="m-right">
|
||||
<div class="m-diffhead">
|
||||
<span class="m-stat a" id="mStat" style="width:24px;height:24px;font-size:13px">+</span>
|
||||
<span class="ttl"><span class="n"><span id="mName">SM_Guardrail.uasset</span> <span id="mDir">— //depot/Aurora_Road/Content/Meshes/</span></span><span class="mm" id="mMeta">add · #1 · binary</span></span>
|
||||
<span class="m-revert"><svg viewBox="0 0 24 24" fill="none"><path d="M9 14 4 9l5-5M4 9h11a5 5 0 0 1 0 10h-3" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg> Revert</span>
|
||||
</div>
|
||||
<div class="m-stage" id="mStage">
|
||||
<span class="lab" id="mLab"><svg viewBox="0 0 24 24" width="13" height="13" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg> <span id="mLabTxt">3D preview</span></span>
|
||||
|
||||
<div class="pv pv-mesh" data-pv="mesh">
|
||||
<svg class="m-cube" id="mCube" viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/><path d="m3 7 9 5 9-5M12 12v10" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>
|
||||
<span class="m-load" id="mLoad"><svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg> <span id="mLoadTxt">Load 3D model</span></span>
|
||||
</div>
|
||||
|
||||
<div class="pv pv-tex" data-pv="tex" hidden>
|
||||
<div class="tex-swatch"></div>
|
||||
<span class="tex-cap">2048 × 2048 · BC7 · sRGB</span>
|
||||
</div>
|
||||
|
||||
<div class="pv pv-code" data-pv="code" hidden>
|
||||
<div class="codeblk"><span class="ln"><span class="cm">// M5 — deterministic POI placement over the road graph</span></span><span class="ln"><span class="kw">void</span> <span class="ty">URoadSampler</span>::<span class="fn">Build</span>(<span class="kw">const</span> <span class="ty">FRoadConfig</span>& Cfg)</span><span class="ln">{</span><span class="ln"> Graph = <span class="fn">NewObject</span><<span class="ty">URoadGraph</span>>(<span class="kw">this</span>);</span><span class="ln"> Graph-><span class="fn">Initialize</span>(Cfg);</span><span class="ln"> Graph-><span class="fn">EnsureGeneratedAround</span>(<span class="ty">INDEX_NONE</span>, <span class="st">0.0</span>);</span><span class="ln"> Pois = <span class="fn">NewObject</span><<span class="ty">UPoiRegistry</span>>(<span class="kw">this</span>);</span><span class="ln">}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="m-metabar">
|
||||
<div class="mi"><div class="k">Type</div><div class="v" id="mvType">binary +l</div></div>
|
||||
<div class="mi"><div class="k">Preview</div><div class="v" id="mvPrev">3D mesh</div></div>
|
||||
<div class="mi"><div class="k">Revision</div><div class="v acc">#1</div></div>
|
||||
<div class="mi"><div class="k">Change</div><div class="v">default</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- status bar -->
|
||||
<div class="m-status">
|
||||
<span class="ok">✓</span><span class="cmd">p4 opened -c default //depot/Aurora_Road/… {3 files}</span>
|
||||
<span class="chips">
|
||||
<b><svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg> Unreal</b>
|
||||
<b><svg viewBox="0 0 24 24" fill="none"><path d="M4 5h16v14H4z" stroke="currentColor" stroke-width="1.6"/><path d="m7 9 2.5 2.5L7 14m5 0h4" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg> Terminal</b>
|
||||
<b><svg viewBox="0 0 24 24" fill="none"><path d="M4 6h16M4 12h16M4 18h10" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg> Log</b>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== TRUST ===================== -->
|
||||
<div class="trust wrap">
|
||||
<p>Purpose-built for game studios on Perforce</p>
|
||||
<div class="trust-row">
|
||||
<span class="chip"><svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg> Unreal Engine 4 & 5</span>
|
||||
<a class="chip" href="https://www.perforce.com/products/helix-core" target="_blank" rel="noopener"><svg viewBox="0 0 24 24" fill="none"><rect x="3" y="4" width="18" height="14" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M8 20h8" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg> Powered by Perforce Helix Core</a>
|
||||
<span class="chip"><svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.6"/><path d="M3 12h18M12 3c2.5 2.5 2.5 15 0 18M12 3c-2.5 2.5-2.5 15 0 18" stroke="currentColor" stroke-width="1.4"/></svg> Radmin VPN friendly</span>
|
||||
<span class="chip"><svg viewBox="0 0 24 24" fill="none"><path d="M4 7h16M4 12h16M4 17h10" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg> 5 languages</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ===================== FEATURES ===================== -->
|
||||
<section id="features">
|
||||
<div class="wrap">
|
||||
<div class="sec-head">
|
||||
<span class="eyebrow"><span class="dot"></span> Everything, in one native app</span>
|
||||
<h2 style="margin-top:18px">Built for the way Unreal teams actually work</h2>
|
||||
<p>Perforce is the industry standard for a reason. Exbyte Depot keeps every bit of its power and finally makes it feel good to use.</p>
|
||||
</div>
|
||||
<div class="grid">
|
||||
|
||||
<div class="card">
|
||||
<span class="tag">Signature</span>
|
||||
<div class="ic"><svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="m3 7 9 5 9-5M12 12v10" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg></div>
|
||||
<h3>Real 3D asset preview</h3>
|
||||
<p>Rotate the actual mesh inside a <code>.uasset</code> right in the app — plus embedded thumbnails, image, audio and video previews. See what you're committing before you commit it.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="ic"><svg viewBox="0 0 24 24" fill="none"><rect x="5" y="11" width="14" height="9" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M8 11V8a4 4 0 0 1 8 0v3" stroke="currentColor" stroke-width="1.6"/></svg></div>
|
||||
<h3>Exclusive locks, one click</h3>
|
||||
<p>Binary <code>.uasset</code> files can't be merged. Take an exclusive lock so only you edit a mesh or material at a time — and see everyone else's locks live. No more overwritten work.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="ic"><svg viewBox="0 0 24 24" fill="none"><path d="M4 12a8 8 0 0 1 8-8 8 8 0 0 1 8 8m0 0-3-3m3 3 3-3M20 12a8 8 0 0 1-8 8 8 8 0 0 1-8-8m0 0 3 3m-3-3-3 3" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
|
||||
<h3>Work offline, reconcile later</h3>
|
||||
<p>On a plane or the VPN's down? Keep editing. Exbyte Depot scans your workspace and reconciles adds, edits and deletes into a changelist when you're back — just like P4V, but nicer.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="ic"><svg viewBox="0 0 24 24" fill="none"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/><path d="M13.7 21a2 2 0 0 1-3.4 0" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg></div>
|
||||
<h3>Live teammate activity</h3>
|
||||
<p>Lives in your system tray and sends a native notification the moment a teammate submits — with their message. A banner tells you when you're behind, one click to Get Latest.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<span class="tag">AI</span>
|
||||
<div class="ic"><svg viewBox="0 0 24 24" fill="none"><path d="M12 3v3m0 12v3M5 12H2m20 0h-3M6 6l2 2m8 8 2 2m0-12-2 2M8 16l-2 2" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><circle cx="12" cy="12" r="3.2" stroke="currentColor" stroke-width="1.6"/></svg></div>
|
||||
<h3>AI commit summaries</h3>
|
||||
<p>Draft a clear changelist message from your selected files, and get a plain-English summary of what any source file does — in your interface language.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="ic"><svg viewBox="0 0 24 24" fill="none"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" stroke="currentColor" stroke-width="1.6"/></svg></div>
|
||||
<h3>Files & Sizes explorer</h3>
|
||||
<p>Browse the Depot and your Workspace as a tree and see exactly how much every file and folder weighs — heaviest first. Find what's bloating your repo in seconds.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="ic"><svg viewBox="0 0 24 24" fill="none"><path d="m9 8-4 4 4 4m6-8 4 4-4 4" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
|
||||
<h3>Built-in code editor</h3>
|
||||
<p>Fix a header or a config right before commit — no external IDE needed. Full syntax highlighting, line numbers, and a proper editor, not a grey textbox.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="ic"><svg viewBox="0 0 24 24" fill="none"><path d="M6 3v12a3 3 0 0 0 3 3h6M6 3a3 3 0 1 0 0 6M6 3a3 3 0 1 1 0 6m9 9a3 3 0 1 0 6 0 3 3 0 0 0-6 0Z" stroke="currentColor" stroke-width="1.6"/></svg></div>
|
||||
<h3>The full Perforce toolbox</h3>
|
||||
<p>Labels, streams, integrate / merge / copy, jobs, file history with blame & diff, clean, <code>.p4ignore</code>, get-revision, shelve/unshelve — the depth is all there when you need it.</p>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="ic"><svg viewBox="0 0 24 24" fill="none"><path d="M21 12a9 9 0 1 1-9-9c2.5 0 4.5 1 6 2.5" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><path d="m17 4 1 3-3 1" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
|
||||
<h3>Signed auto-updates</h3>
|
||||
<p>Ships as a signed installer with a built-in updater. New versions land in place — the whole team stays current without chasing downloads.</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== WHY NOT P4V ===================== -->
|
||||
<section id="why" style="background:linear-gradient(180deg,transparent,rgba(124,110,246,.04),transparent)">
|
||||
<div class="wrap split">
|
||||
<div>
|
||||
<span class="eyebrow"><span class="dot"></span> Why switch</span>
|
||||
<h2 style="margin-top:18px">P4V is powerful. It's also from another era.</h2>
|
||||
<p class="lead">We're not replacing your <a class="tlink" href="https://www.perforce.com/" target="_blank" rel="noopener">Perforce</a> server — we're replacing the client you look at it through. Exbyte Depot keeps every bit of Perforce power your studio depends on and rebuilds the experience around how artists and engineers really work: visual, fast, and forgiving.</p>
|
||||
<div class="comp">
|
||||
<div class="crow good"><span class="ck"><svg viewBox="0 0 24 24" fill="none"><path d="m5 12 4 4 10-10" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span><div><b>See your assets, not filenames</b><span>Live 3D meshes, thumbnails, audio & video — right where you review a changelist.</span></div></div>
|
||||
<div class="crow good"><span class="ck"><svg viewBox="0 0 24 24" fill="none"><path d="m5 12 4 4 10-10" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span><div><b>Commit locally, submit when ready</b><span>A GitHub-Desktop-style flow on top of Perforce — revertable local commits, then push.</span></div></div>
|
||||
<div class="crow good"><span class="ck"><svg viewBox="0 0 24 24" fill="none"><path d="m5 12 4 4 10-10" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span><div><b>Never lose work to a binary conflict</b><span>Exclusive locks on <code>.uasset</code> are one click, and everyone's locks are visible.</span></div></div>
|
||||
<div class="crow good"><span class="ck"><svg viewBox="0 0 24 24" fill="none"><path d="m5 12 4 4 10-10" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span><div><b>Know what the team is doing</b><span>Tray notifications on every submit and a clear "you're behind" nudge.</span></div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel-visual">
|
||||
<div style="font-size:12px;letter-spacing:.1em;text-transform:uppercase;color:var(--faint);margin-bottom:14px;display:flex;align-items:center;gap:8px"><svg viewBox="0 0 24 24" width="15" height="15" fill="none" style="color:var(--acc-2)"><rect x="5" y="11" width="14" height="9" rx="2" stroke="currentColor" stroke-width="1.6"/><path d="M8 11V8a4 4 0 0 1 8 0v3" stroke="currentColor" stroke-width="1.6"/></svg> File Locks</div>
|
||||
<div class="lock-item"><span class="lock-badge red"><svg viewBox="0 0 24 24" fill="none"><rect x="5" y="11" width="14" height="9" rx="2" stroke="currentColor" stroke-width="1.7"/><path d="M8 11V8a4 4 0 0 1 8 0v3" stroke="currentColor" stroke-width="1.7"/></svg></span><div class="li-b"><div class="n">SM_Hero_Character.uasset</div><div class="p">//depot/Char/Content/Meshes/</div></div><span class="who">🔒 mira_art</span></div>
|
||||
<div class="lock-item"><span class="lock-badge red"><svg viewBox="0 0 24 24" fill="none"><rect x="5" y="11" width="14" height="9" rx="2" stroke="currentColor" stroke-width="1.7"/><path d="M8 11V8a4 4 0 0 1 8 0v3" stroke="currentColor" stroke-width="1.7"/></svg></span><div class="li-b"><div class="n">M_Road_Master.uasset</div><div class="p">//depot/Road/Content/Materials/</div></div><span class="who">🔒 you</span></div>
|
||||
<div class="lock-item"><span class="lock-badge gr"><svg viewBox="0 0 24 24" fill="none"><rect x="5" y="11" width="14" height="9" rx="2" stroke="currentColor" stroke-width="1.7"/><path d="M8 11V8a4 4 0 0 1 8 0v3" stroke="currentColor" stroke-width="1.7"/></svg></span><div class="li-b"><div class="n">BP_GameMode.uasset</div><div class="p">//depot/Core/Content/Blueprints/</div></div><span class="who">available</span></div>
|
||||
<div style="margin-top:16px;padding:13px 15px;border-radius:11px;background:rgba(124,110,246,.08);border:1px solid rgba(124,110,246,.22);font-size:13px;color:var(--acc-2);display:flex;gap:10px;align-items:center">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round"/></svg>
|
||||
voss_dev just submitted <b style="color:#fff;margin:0 4px">#34</b> — you're 3 files behind.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== TECH ===================== -->
|
||||
<section id="tech">
|
||||
<div class="wrap">
|
||||
<div class="sec-head">
|
||||
<span class="eyebrow"><span class="dot"></span> Under the hood</span>
|
||||
<h2 style="margin-top:18px">Native, fast, and quietly powerful</h2>
|
||||
<p>No Electron bloat. A tiny signed installer, instant startup, and a UI that stays out of your way.</p>
|
||||
</div>
|
||||
<div class="tech">
|
||||
<div class="t"><div class="k">Rust</div><div class="v">Native Tauri backend</div></div>
|
||||
<div class="t"><div class="k">~4 MB</div><div class="v">Installer, not 200</div></div>
|
||||
<div class="t"><div class="k">5</div><div class="v">Languages built in</div></div>
|
||||
<div class="t"><div class="k">0</div><div class="v">Servers to configure</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== FINAL CTA ===================== -->
|
||||
<section style="padding-top:20px">
|
||||
<div class="wrap">
|
||||
<div class="cta">
|
||||
<h2>Get early access to Exbyte Depot</h2>
|
||||
<p>We're onboarding studios in waves. Drop your email and we'll send your download the moment your spot opens.</p>
|
||||
<div class="waitlist">
|
||||
<form class="wl-form" id="wlForm2" novalidate>
|
||||
<input id="wlEmail2" type="email" inputmode="email" autocomplete="email" placeholder="you@studio.com" aria-label="Email address" required />
|
||||
<button class="btn primary" type="submit">Reserve my spot
|
||||
<svg viewBox="0 0 24 24" fill="none"><path d="M5 12h14m-6-6 6 6-6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
</button>
|
||||
</form>
|
||||
<div class="wl-ok" id="wlOk2">
|
||||
<svg viewBox="0 0 24 24" fill="none"><circle cx="12" cy="12" r="9" stroke="currentColor" stroke-width="1.7"/><path d="m8.5 12 2.5 2.5 4.5-5" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
<span>You're on the list. Talk soon.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ===================== FOOTER ===================== -->
|
||||
<footer>
|
||||
<div class="wrap foot">
|
||||
<a class="brand" href="#top">
|
||||
<span class="mark"><svg viewBox="0 0 24 24" fill="none"><path d="M12 2 3 7v10l9 5 9-5V7l-9-5Z" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></svg></span>
|
||||
Exbyte Depot
|
||||
</a>
|
||||
<span class="fl" style="margin-left:0">
|
||||
<a href="#features">Features</a>
|
||||
<a href="#waitlist">Waitlist</a>
|
||||
<a class="tlink" href="https://www.perforce.com/" target="_blank" rel="noopener">Perforce.com</a>
|
||||
</span>
|
||||
<span style="flex-basis:100%;color:var(--faint);font-size:12.5px;line-height:1.6;margin-top:6px">
|
||||
© <span id="yr"></span> Exbyte Studios. Exbyte Depot is an independent desktop <b style="color:var(--muted);font-weight:600">client for Perforce Helix Core</b> and requires a Perforce server. It is not affiliated with, endorsed by, or sponsored by Perforce Software, Inc. “Perforce”, “Helix Core” and “P4V” are trademarks of <a class="tlink" href="https://www.perforce.com/" target="_blank" rel="noopener">Perforce Software, Inc.</a>, used here only to describe compatibility.
|
||||
</span>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
(function(){
|
||||
"use strict";
|
||||
var KEY = "exd_waitlist";
|
||||
function list(){ try { return JSON.parse(localStorage.getItem(KEY) || "[]"); } catch(e){ return []; } }
|
||||
function save(a){ try { localStorage.setItem(KEY, JSON.stringify(a)); } catch(e){} }
|
||||
|
||||
// Signups POST to the waitlist server (../waitlist-server). The server dedups
|
||||
// and stores them; localStorage is kept as a mirror for the social-proof
|
||||
// counter and as an offline fallback if the page is opened without a backend.
|
||||
var API = "/api/waitlist";
|
||||
function submitToWaitlist(email){
|
||||
var a = list();
|
||||
if (a.indexOf(email) === -1) a.push(email);
|
||||
save(a);
|
||||
return fetch(API, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: email, source: "landing", website: "" })
|
||||
})
|
||||
.then(function(r){ return r.ok ? r.json() : null; })
|
||||
.catch(function(){ return null; }); // never block the UX on a network error
|
||||
}
|
||||
|
||||
var BASE = 1240; // shown alongside real signups for social proof
|
||||
function renderCount(){
|
||||
var el = document.getElementById("wlCount");
|
||||
if (el) el.textContent = (BASE + list().length).toLocaleString() + " studios";
|
||||
}
|
||||
renderCount();
|
||||
document.getElementById("yr").textContent = new Date().getFullYear();
|
||||
|
||||
function valid(v){ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v); }
|
||||
|
||||
function wire(formId, emailId, okId){
|
||||
var form = document.getElementById(formId);
|
||||
if (!form) return;
|
||||
form.addEventListener("submit", function(e){
|
||||
e.preventDefault();
|
||||
var input = document.getElementById(emailId);
|
||||
var email = (input.value || "").trim().toLowerCase();
|
||||
if (!valid(email)){
|
||||
input.focus();
|
||||
input.style.boxShadow = "0 0 0 2px var(--del)";
|
||||
setTimeout(function(){ input.style.boxShadow = ""; }, 1400);
|
||||
return;
|
||||
}
|
||||
var already = list().indexOf(email) !== -1;
|
||||
submitToWaitlist(email).then(function(){
|
||||
form.style.display = "none";
|
||||
var ok = document.getElementById(okId);
|
||||
if (ok){
|
||||
ok.classList.add("show");
|
||||
if (okId === "wlOk"){
|
||||
var msg = document.getElementById("wlOkMsg");
|
||||
if (msg) msg.textContent = already
|
||||
? "You're already on the list — we've got you."
|
||||
: "You're on the list. We'll email you the moment early access opens.";
|
||||
}
|
||||
}
|
||||
renderCount();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
wire("wlForm", "wlEmail", "wlOk");
|
||||
wire("wlForm2", "wlEmail2", "wlOk2");
|
||||
})();
|
||||
|
||||
/* ===== interactive app-window mock ===== */
|
||||
(function(){
|
||||
"use strict";
|
||||
var mock = document.querySelector(".mock");
|
||||
if (!mock) return;
|
||||
var $ = function(s){ return mock.querySelector(s); };
|
||||
var $$ = function(s){ return Array.prototype.slice.call(mock.querySelectorAll(s)); };
|
||||
|
||||
/* --- tab switching (Changes / History) --- */
|
||||
$$(".m-tabs span").forEach(function(tab){
|
||||
tab.addEventListener("click", function(){
|
||||
var name = tab.getAttribute("data-tab");
|
||||
$$(".m-tabs span").forEach(function(t){ t.classList.toggle("on", t === tab); });
|
||||
$$(".m-panel").forEach(function(p){ p.hidden = p.getAttribute("data-panel") !== name; });
|
||||
});
|
||||
});
|
||||
|
||||
/* --- file selection swaps the right-hand preview --- */
|
||||
function selectFile(f){
|
||||
$$(".m-file").forEach(function(x){ x.classList.toggle("sel", x === f); });
|
||||
var d = f.dataset;
|
||||
$("#mName").textContent = d.name;
|
||||
$("#mDir").textContent = d.dir;
|
||||
$("#mMeta").textContent = d.meta;
|
||||
$("#mvType").textContent = d.type;
|
||||
$("#mvPrev").textContent = d.prev;
|
||||
$("#mLabTxt").textContent = d.lab;
|
||||
$$(".pv").forEach(function(pv){ pv.hidden = pv.getAttribute("data-pv") !== d.kind; });
|
||||
}
|
||||
$$(".m-file").forEach(function(f){
|
||||
f.addEventListener("click", function(e){
|
||||
if (e.target.closest(".m-chk")) return; // checkbox handled separately
|
||||
selectFile(f);
|
||||
});
|
||||
});
|
||||
|
||||
/* --- checkboxes toggle the selected count + commit button --- */
|
||||
function refreshCount(){
|
||||
var on = $$(".m-file:not(.off)").length;
|
||||
$("#mSelTxt").textContent = on + " of 3 selected";
|
||||
$("#mCommitTxt").textContent = on ? ("Commit changelist · " + on + (on === 1 ? " file" : " files")) : "Select files to commit";
|
||||
$("#mCommit").style.opacity = on ? "1" : ".55";
|
||||
}
|
||||
$$(".m-file .m-chk").forEach(function(chk){
|
||||
chk.addEventListener("click", function(e){
|
||||
e.stopPropagation();
|
||||
chk.closest(".m-file").classList.toggle("off");
|
||||
refreshCount();
|
||||
});
|
||||
});
|
||||
|
||||
/* --- little press / spin feedback --- */
|
||||
function pressSpin(el, ms){
|
||||
if (!el || el.classList.contains("spin")) return;
|
||||
el.classList.add("spin");
|
||||
setTimeout(function(){ el.classList.remove("spin"); }, ms || 900);
|
||||
}
|
||||
var rf = $("#mRefresh"); if (rf) rf.addEventListener("click", function(){ pressSpin(rf, 800); });
|
||||
var get = $(".m-zone.get"); if (get) get.addEventListener("click", function(){ pressSpin(get, 1100); });
|
||||
|
||||
/* --- Load 3D model → spinner, then a slowly spinning mesh --- */
|
||||
var load = $("#mLoad"), cube = $("#mCube"), loaded = false;
|
||||
if (load){
|
||||
load.addEventListener("click", function(){
|
||||
if (loaded){ return; }
|
||||
var txt = $("#mLoadTxt");
|
||||
load.innerHTML = '<span class="m-spinner"></span> Rendering…';
|
||||
setTimeout(function(){
|
||||
loaded = true;
|
||||
cube.classList.add("loaded");
|
||||
load.innerHTML = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none"><path d="m5 12 4 4 10-10" stroke="currentColor" stroke-width="2.3" stroke-linecap="round" stroke-linejoin="round"/></svg> Loaded';
|
||||
load.style.opacity = ".7";
|
||||
var pv = $("#mvPrev"); if (pv && pv.textContent.indexOf("3D") === 0) pv.textContent = "3D mesh · loaded";
|
||||
}, 950);
|
||||
});
|
||||
}
|
||||
|
||||
/* --- Commit button press --- */
|
||||
var commit = $("#mCommit");
|
||||
if (commit){
|
||||
commit.addEventListener("click", function(){
|
||||
if ($$(".m-file:not(.off)").length === 0) return;
|
||||
var t = $("#mCommitTxt"), old = t.textContent;
|
||||
commit.style.transform = "scale(.98)";
|
||||
t.textContent = "Committing…";
|
||||
setTimeout(function(){
|
||||
commit.style.transform = "";
|
||||
t.textContent = "✓ Changelist created locally";
|
||||
setTimeout(function(){ t.textContent = old; }, 1500);
|
||||
}, 700);
|
||||
});
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
14
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "exbyte-depot",
|
||||
"private": true,
|
||||
"version": "0.2.2",
|
||||
"version": "0.4.2",
|
||||
"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",
|
||||
|
||||
57
plugins-examples/hello-exbyte/index.js
Normal file
@ -0,0 +1,57 @@
|
||||
// Hello Exbyte — example plugin.
|
||||
// A plugin is a plain ES module that exports `activate(host)`. The host is the
|
||||
// only way the plugin touches the app; what it can reach is gated by the
|
||||
// "permissions" in plugin.json.
|
||||
|
||||
export function activate(host) {
|
||||
host.log("activating", host.plugin.id, "v", host.version);
|
||||
|
||||
// 1) A menu action (appears under the Actions menu).
|
||||
host.ui.addMenuItem("Say hello 👋", () => {
|
||||
const me = host.info()?.userName || "there";
|
||||
host.flash(`Hello, ${me}! (from the Hello Exbyte plugin)`);
|
||||
host.notify("Hello Exbyte", "The example plugin says hi.");
|
||||
});
|
||||
|
||||
// 2) A file context-menu item (right-click a file in Changes).
|
||||
host.ui.addFileContextItem("Copy depot path (plugin)", (file) => {
|
||||
if (file.depotFile) {
|
||||
host.clipboard.write(file.depotFile);
|
||||
host.flash("Copied: " + file.depotFile);
|
||||
}
|
||||
});
|
||||
|
||||
// 3) A view — a per-workspace notepad, persisted with host.storage.
|
||||
// Opens from the Actions menu (listed by its title).
|
||||
host.ui.addView({
|
||||
id: "notes",
|
||||
title: "Workspace Notes",
|
||||
render: (el) => {
|
||||
const key = "notes:" + (host.info()?.clientName || "default");
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = host.storage.get(key) || "";
|
||||
ta.placeholder = "Jot notes for this workspace… (saved locally, per workspace)";
|
||||
ta.style.cssText =
|
||||
"width:100%;height:320px;box-sizing:border-box;resize:vertical;" +
|
||||
"background:var(--input-bg,#111);color:var(--txt,#eee);" +
|
||||
"border:1px solid var(--border,#333);border-radius:10px;padding:12px;" +
|
||||
"font:13px/1.6 system-ui;outline:none";
|
||||
ta.addEventListener("input", () => host.storage.set(key, ta.value));
|
||||
el.appendChild(ta);
|
||||
// optional cleanup returned to the host:
|
||||
return () => host.storage.set(key, ta.value);
|
||||
},
|
||||
});
|
||||
|
||||
// 4) A submit guard — blocks (with a confirm) files that look like WIP.
|
||||
// This is the same hook point a reference-integrity guard would use.
|
||||
host.ui.addSubmitHook((files) => {
|
||||
const wip = files.filter((f) => /(^|\/)(wip|temp|scratch)[^/]*$/i.test(f.depotFile || ""));
|
||||
if (wip.length) {
|
||||
return { ok: false, message: `${wip.length} file(s) look like work-in-progress (wip / temp / scratch).` };
|
||||
}
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
host.log("activated");
|
||||
}
|
||||
11
plugins-examples/hello-exbyte/plugin.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"id": "hello-exbyte",
|
||||
"name": "Hello Exbyte",
|
||||
"version": "1.0.0",
|
||||
"author": "Exbyte Studios",
|
||||
"description": "Example plugin — a menu action, a per-workspace notes panel, a file context item, and a submit guard. Copy it as a starting point.",
|
||||
"entry": "index.js",
|
||||
"minAppVersion": "0.3.5",
|
||||
"permissions": ["p4:read", "notify"],
|
||||
"contributes": { "menu": true, "views": ["notes"], "fileContext": true, "submitHooks": true }
|
||||
}
|
||||
243
plugins-examples/team-relay/index.js
Normal file
@ -0,0 +1,243 @@
|
||||
// Team Relay — presence + coordinator tools over a GENERIC relay.
|
||||
//
|
||||
// The relay (see exbyte-relay/) is a dumb message bus: it routes opaque JSON and
|
||||
// tracks who's online, and knows nothing about Perforce. ALL the logic lives
|
||||
// here and runs through the permission-gated `host.team` bridge:
|
||||
// - every ~4s we announce presence (user, #open files, access level) and drain
|
||||
// our inbox in one /sync round-trip;
|
||||
// - a coordinator (admin/super) can ask a teammate's client to shelve its open
|
||||
// work to the server — the teammate's client does the shelve, nothing is
|
||||
// submitted, and their files stay checked out (shelve is non-destructive).
|
||||
//
|
||||
// Relay URL/token/room are configured in the Team tab and stored per-plugin —
|
||||
// nothing is hardcoded into the app.
|
||||
|
||||
export function activate(host) {
|
||||
const team = host.team;
|
||||
if (!team) { host.log('no team bridge — plugin.json needs the "team" permission'); return; }
|
||||
|
||||
const S = host.storage;
|
||||
if (!S.get("room")) S.set("room", "team");
|
||||
const getCfg = () => ({
|
||||
url: (S.get("relayUrl") || "").trim().replace(/\/+$/, ""),
|
||||
token: (S.get("relayToken") || "").trim(),
|
||||
room: (S.get("room") || "team").trim() || "team",
|
||||
});
|
||||
|
||||
const me = team.whoami() || {};
|
||||
const myId = me.client || me.user || "unknown";
|
||||
let access = "";
|
||||
team.maxAccess().then((a) => { access = String(a || "").toLowerCase(); updateAll(); }).catch(() => {});
|
||||
const isCoordinator = () => access === "super" || access === "admin";
|
||||
|
||||
let members = [];
|
||||
let status = "idle"; // idle | ok | error
|
||||
let statusMsg = "";
|
||||
const updaters = new Set();
|
||||
const updateAll = () => updaters.forEach((fn) => { try { fn(); } catch (_) {} });
|
||||
|
||||
// ---- relay I/O (backend HTTP pipe → no CORS, any http(s) URL) --------------
|
||||
async function post(path, bodyObj) {
|
||||
const c = getCfg();
|
||||
if (!c.url) throw new Error("Relay URL not set");
|
||||
const txt = await team.relay(c.url + path, "POST", JSON.stringify(bodyObj), c.token);
|
||||
return JSON.parse(txt || "{}");
|
||||
}
|
||||
async function send(to, type, payload) {
|
||||
const c = getCfg();
|
||||
await post("/send", { room: c.room, to, from: myId, type, payload: payload || {} });
|
||||
}
|
||||
|
||||
// ---- inbound message handling ---------------------------------------------
|
||||
async function handle(msg) {
|
||||
const from = msg.from || "A teammate";
|
||||
if (msg.type === "shelve-request") {
|
||||
host.flash(from + " is shelving your open work to the server…");
|
||||
host.notify("Team Relay", from + " is shelving your open work to the server…");
|
||||
try {
|
||||
const n = await team.openCount();
|
||||
if (!n) { await send(from, "shelve-result", { ok: true, change: "", files: 0 }); return; }
|
||||
const r = await team.shelveOpen("Remote shelve requested by " + from + " via Team Relay");
|
||||
host.notify("Team Relay", "Your open work was shelved as CL " + r.change + " (" + r.files + " file(s)). Your files stay checked out.");
|
||||
await send(from, "shelve-result", { ok: true, change: r.change, files: r.files });
|
||||
} catch (e) {
|
||||
host.flash("Remote shelve failed: " + e, true);
|
||||
await send(from, "shelve-result", { ok: false, error: String(e) });
|
||||
}
|
||||
} else if (msg.type === "shelve-result") {
|
||||
const p = msg.payload || {};
|
||||
if (p.ok && p.change) host.flash(from + ": shelved " + p.files + " file(s) as CL " + p.change + " — find it under Pending / Shelved.");
|
||||
else if (p.ok) host.flash(from + ": nothing open to shelve.");
|
||||
else host.flash(from + ": shelve failed — " + p.error, true);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- presence loop (one global timer, survives plugin reloads) ------------
|
||||
let busy = false;
|
||||
async function tick() {
|
||||
if (busy) return;
|
||||
busy = true;
|
||||
try {
|
||||
const c = getCfg();
|
||||
if (!c.url) { status = "idle"; statusMsg = "Set the relay URL below."; return; }
|
||||
const openCount = await team.openCount().catch(() => 0);
|
||||
const res = await post("/sync", {
|
||||
room: c.room, id: myId, name: me.user || myId,
|
||||
meta: { client: myId, user: me.user || "", openCount, access },
|
||||
});
|
||||
members = Array.isArray(res.members) ? res.members : [];
|
||||
status = "ok";
|
||||
statusMsg = members.length + (members.length === 1 ? " online" : " online");
|
||||
for (const m of (Array.isArray(res.messages) ? res.messages : [])) await handle(m);
|
||||
} catch (e) {
|
||||
status = "error";
|
||||
statusMsg = String(e).replace(/^Error:\s*/, "");
|
||||
} finally {
|
||||
busy = false;
|
||||
updateAll();
|
||||
}
|
||||
}
|
||||
// Single presence loop even if the plugin is reloaded (blob module re-imported).
|
||||
if (typeof window !== "undefined") {
|
||||
if (window.__exbyteTeamRelayTimer) clearInterval(window.__exbyteTeamRelayTimer);
|
||||
window.__exbyteTeamRelayTimer = setInterval(tick, 4000);
|
||||
}
|
||||
tick();
|
||||
|
||||
// ---- UI helpers ------------------------------------------------------------
|
||||
function styleBtn(b, primary) {
|
||||
b.style.cssText =
|
||||
"padding:4px 10px;border-radius:6px;cursor:pointer;white-space:nowrap;font:12px var(--sans,system-ui,sans-serif);" +
|
||||
(primary
|
||||
? "background:var(--accent,#7c6ef6);color:#fff;border:1px solid var(--accent,#7c6ef6)"
|
||||
: "background:transparent;color:var(--muted,#9a9aa8);border:1px solid var(--border,#33333d)");
|
||||
if (b.disabled) { b.style.opacity = "0.5"; b.style.cursor = "default"; }
|
||||
}
|
||||
|
||||
function buildSettings() {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.style.cssText =
|
||||
"display:flex;flex-direction:column;gap:6px;padding:9px;border:1px solid var(--border,#26262e);border-radius:8px;background:var(--panel,#17171d)";
|
||||
const title = document.createElement("div");
|
||||
title.textContent = "Relay settings";
|
||||
title.style.cssText = "font-weight:600;margin-bottom:2px";
|
||||
const field = (label, key, ph, pw) => {
|
||||
const l = document.createElement("label");
|
||||
l.style.cssText = "display:flex;flex-direction:column;gap:2px;font-size:11px;color:var(--muted,#9a9aa8)";
|
||||
l.appendChild(document.createTextNode(label));
|
||||
const inp = document.createElement("input");
|
||||
inp.type = pw ? "password" : "text";
|
||||
inp.placeholder = ph || "";
|
||||
inp.value = S.get(key) || "";
|
||||
inp.style.cssText =
|
||||
"padding:5px 7px;border:1px solid var(--border,#33333d);border-radius:6px;background:var(--bg,#0e0e12);color:var(--txt,#e8e8ee);font:12px var(--mono,ui-monospace,monospace)";
|
||||
inp.addEventListener("input", () => S.set(key, inp.value));
|
||||
l.appendChild(inp);
|
||||
return l;
|
||||
};
|
||||
const save = document.createElement("button");
|
||||
save.textContent = "Connect";
|
||||
styleBtn(save, true);
|
||||
save.addEventListener("click", () => { host.flash("Relay settings saved."); tick(); });
|
||||
wrap.append(
|
||||
title,
|
||||
field("Relay URL", "relayUrl", "http://26.0.0.1:8787"),
|
||||
field("Token (optional)", "relayToken", "shared secret", true),
|
||||
field("Room", "room", "team"),
|
||||
save,
|
||||
);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderMembers(container) {
|
||||
container.textContent = "";
|
||||
if (!getCfg().url) return;
|
||||
if (!members.length) {
|
||||
const empty = document.createElement("div");
|
||||
empty.style.cssText = "color:var(--muted,#9a9aa8);padding:8px 0";
|
||||
empty.textContent = status === "error" ? "Can't reach the relay — check the URL/token." : "No teammates online yet.";
|
||||
container.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
for (const m of members) {
|
||||
const meta = m.meta || {};
|
||||
const oc = Number(meta.openCount || 0);
|
||||
const row = document.createElement("div");
|
||||
row.style.cssText = "display:flex;align-items:center;gap:8px;padding:7px 0;border-top:1px solid var(--border,#26262e)";
|
||||
const infoCol = document.createElement("div");
|
||||
infoCol.style.flex = "1";
|
||||
const name = document.createElement("div");
|
||||
name.style.fontWeight = "600";
|
||||
name.textContent = (m.name || m.id) + (m.id === myId ? " (you)" : "");
|
||||
const sub = document.createElement("div");
|
||||
sub.style.cssText = "color:var(--muted,#9a9aa8);font-size:11px";
|
||||
sub.textContent = m.id + " · " + oc + " open" + (meta.access ? " · " + meta.access : "");
|
||||
infoCol.append(name, sub);
|
||||
row.appendChild(infoCol);
|
||||
|
||||
if (isCoordinator() && m.id !== myId) {
|
||||
const btn = document.createElement("button");
|
||||
const idle = oc > 0 ? "Force shelve" : "Nothing open";
|
||||
btn.textContent = idle;
|
||||
btn.disabled = oc === 0;
|
||||
styleBtn(btn, oc > 0);
|
||||
if (oc > 0) {
|
||||
btn.title = "Ask this teammate's client to shelve their open work to the server (nothing is submitted).";
|
||||
btn.addEventListener("click", async () => {
|
||||
btn.disabled = true; btn.textContent = "Requesting…"; styleBtn(btn, false);
|
||||
try { await send(m.id, "shelve-request", {}); host.flash("Asked " + (m.name || m.id) + " to shelve their open work…"); }
|
||||
catch (e) { host.flash("Couldn't reach the relay: " + e, true); }
|
||||
setTimeout(() => { btn.disabled = false; btn.textContent = idle; styleBtn(btn, true); }, 2500);
|
||||
});
|
||||
}
|
||||
row.appendChild(btn);
|
||||
}
|
||||
container.appendChild(row);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- dock panel ("Team") ---------------------------------------------------
|
||||
host.ui.addDockPanel({
|
||||
id: "team",
|
||||
title: "Team",
|
||||
icon: "people",
|
||||
render: (el) => {
|
||||
el.textContent = "";
|
||||
el.style.cssText =
|
||||
"display:flex;flex-direction:column;height:100%;gap:9px;padding:11px 13px;overflow:auto;" +
|
||||
"font:12px/1.5 var(--sans,system-ui,sans-serif);color:var(--txt,#e8e8ee)";
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.style.cssText = "display:flex;align-items:center;gap:8px";
|
||||
const dot = document.createElement("span");
|
||||
dot.style.cssText = "width:8px;height:8px;border-radius:50%;flex:0 0 auto";
|
||||
const statusText = document.createElement("span");
|
||||
statusText.style.cssText = "color:var(--muted,#9a9aa8);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap";
|
||||
const gear = document.createElement("button");
|
||||
gear.textContent = "Settings";
|
||||
styleBtn(gear, false);
|
||||
head.append(dot, statusText, gear);
|
||||
|
||||
const settings = buildSettings();
|
||||
settings.style.display = getCfg().url ? "none" : "flex";
|
||||
gear.addEventListener("click", () => {
|
||||
settings.style.display = settings.style.display === "none" ? "flex" : "none";
|
||||
});
|
||||
|
||||
const membersEl = document.createElement("div");
|
||||
el.append(head, settings, membersEl);
|
||||
|
||||
const refresh = () => {
|
||||
dot.style.background =
|
||||
status === "ok" ? "var(--add,#3ecf8e)" : status === "error" ? "var(--del,#ff6b6b)" : "var(--muted,#9a9aa8)";
|
||||
statusText.textContent = statusMsg || "";
|
||||
renderMembers(membersEl);
|
||||
};
|
||||
updaters.add(refresh);
|
||||
refresh();
|
||||
return () => { updaters.delete(refresh); };
|
||||
},
|
||||
});
|
||||
|
||||
host.log("Team Relay activated (id " + myId + ")");
|
||||
}
|
||||
12
plugins-examples/team-relay/plugin.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"id": "team-relay",
|
||||
"name": "Team Relay",
|
||||
"version": "1.0.1",
|
||||
"author": "Exbyte Studios",
|
||||
"description": "See who on the team is online and what they have open, over a lightweight relay on your VPN. Coordinators (admin/super) can force-shelve a teammate's open work to the server when they've stepped away — the teammate's client does the shelve; nothing is submitted. Configure the relay URL in the Team tab. Requires the Exbyte relay service.",
|
||||
"entry": "index.js",
|
||||
"minAppVersion": "0.4.0",
|
||||
"permissions": ["team", "notify"],
|
||||
"defaultEnabled": false,
|
||||
"contributes": { "dock": ["team"] }
|
||||
}
|
||||
51
plugins-examples/unreal/index.js
Normal file
@ -0,0 +1,51 @@
|
||||
// Unreal Engine Tools — official plugin.
|
||||
// The core app keeps the build engine (UnrealBuildTool / MSBuild), the build
|
||||
// overlay and the log reader; this plugin is just the Unreal-facing surface.
|
||||
// It renders natively: the actions sit in the working-folder right-click menu
|
||||
// with the host's Unreal + hammer icons, and the log is a bottom-dock tab —
|
||||
// exactly where these lived before, just delivered as a plugin. Reached through
|
||||
// the permission-gated `host.ue` bridge.
|
||||
|
||||
export function activate(host) {
|
||||
const ue = host.ue;
|
||||
if (!ue) { host.log("no ue bridge — plugin.json needs the \"ue\" permission"); return; }
|
||||
|
||||
// Right-click the working folder → Launch / Build, shown only on a UE project.
|
||||
// Prefer the native folder-context slot (host ≥ 0.3.7); fall back to the top
|
||||
// menu on older hosts so the actions are always reachable.
|
||||
const addAction = (label, run, icon) => {
|
||||
if (host.ui.addFolderContextItem) host.ui.addFolderContextItem(label, run, { icon, visible: () => ue.hasProject() });
|
||||
else host.ui.addMenuItem(label, () => { if (!ue.hasProject()) { host.flash("No Unreal project in the current working folder.", true); return; } run(); }, { icon });
|
||||
};
|
||||
addAction("Launch Unreal Engine", () => ue.launch(), "ue");
|
||||
addAction("Build Solution (.sln)", () => ue.build(), "hammer");
|
||||
|
||||
// Bottom-dock "Unreal" tab — tails the editor log (Saved/Logs), refreshing live.
|
||||
// Native dock panel on host ≥ 0.3.7; a modal view as a fallback on older hosts.
|
||||
const mountLog = (el) => {
|
||||
el.style.cssText = "display:flex;flex-direction:column;height:100%";
|
||||
const pre = document.createElement("pre");
|
||||
pre.style.cssText =
|
||||
"flex:1;margin:0;padding:10px 14px;overflow:auto;white-space:pre-wrap;word-break:break-word;" +
|
||||
"font:11.5px/1.6 var(--mono,ui-monospace,monospace);color:var(--muted,#9a9aa8)";
|
||||
el.appendChild(pre);
|
||||
let alive = true;
|
||||
async function tick() {
|
||||
if (!alive) return;
|
||||
try {
|
||||
if (!ue.hasProject()) { pre.textContent = "No Unreal project in the current working folder."; return; }
|
||||
const atBottom = pre.scrollTop + pre.clientHeight >= pre.scrollHeight - 30;
|
||||
const txt = await ue.readLog(200000);
|
||||
pre.textContent = txt || "No Unreal log yet. It shows here when the editor writes to Saved/Logs — i.e. while Unreal is running.";
|
||||
if (atBottom) pre.scrollTop = pre.scrollHeight;
|
||||
} catch (e) { pre.textContent = String(e); }
|
||||
}
|
||||
tick();
|
||||
const id = setInterval(tick, 1500);
|
||||
return () => { alive = false; clearInterval(id); };
|
||||
};
|
||||
if (host.ui.addDockPanel) host.ui.addDockPanel({ id: "unreal", title: "Unreal", icon: "ue", render: mountLog });
|
||||
else host.ui.addView({ id: "unreal-log", title: "Unreal Log", render: mountLog });
|
||||
|
||||
host.log("Unreal Engine Tools activated");
|
||||
}
|
||||
12
plugins-examples/unreal/plugin.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"id": "unreal",
|
||||
"name": "Unreal Engine Tools",
|
||||
"version": "1.2.0",
|
||||
"author": "Exbyte Studios",
|
||||
"description": "Build the game (.sln / UnrealBuildTool), launch the editor, and tail the Unreal log. Actions live in the working-folder right-click menu; the log is a bottom-dock tab. Enable on Unreal Engine projects.",
|
||||
"entry": "index.js",
|
||||
"minAppVersion": "0.3.6",
|
||||
"permissions": ["ue"],
|
||||
"defaultEnabled": true,
|
||||
"contributes": { "folderContext": true, "dock": ["unreal"] }
|
||||
}
|
||||
368
src-tauri/Cargo.lock
generated
@ -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"
|
||||
@ -462,6 +468,23 @@ version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
@ -552,6 +575,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
@ -874,6 +906,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 +992,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "exbyte-depot"
|
||||
version = "0.2.2"
|
||||
version = "0.4.2"
|
||||
dependencies = [
|
||||
"encoding_rs",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-notification",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-updater",
|
||||
@ -1262,8 +1306,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1285,8 +1331,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core 0.10.1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1555,6 +1604,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@ -1723,6 +1773,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 +2075,26 @@ version = "0.4.33"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[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 +2154,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 +2215,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 +2731,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 +2808,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"
|
||||
@ -2701,6 +2823,62 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn"
|
||||
version = "0.11.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
"pin-project-lite",
|
||||
"quinn-proto",
|
||||
"quinn-udp",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"socket2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"getrandom 0.4.3",
|
||||
"lru-slab",
|
||||
"rand 0.10.2",
|
||||
"rand_pcg",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"slab",
|
||||
"thiserror 2.0.18",
|
||||
"tinyvec",
|
||||
"tracing",
|
||||
"web-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quinn-udp"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
|
||||
dependencies = [
|
||||
"cfg_aliases",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.46"
|
||||
@ -2722,6 +2900,61 @@ 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 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.3",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[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 0.9.5",
|
||||
]
|
||||
|
||||
[[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 = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_pcg"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||
dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@ -2797,6 +3030,44 @@ version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.12.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"futures-core",
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"tower-service",
|
||||
"url",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"webpki-roots",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "reqwest"
|
||||
version = "0.13.4"
|
||||
@ -2934,6 +3205,7 @@ version = "1.15.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@ -2981,6 +3253,12 @@ version = "1.0.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||
|
||||
[[package]]
|
||||
name = "ryu"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "same-file"
|
||||
version = "1.0.6"
|
||||
@ -3203,6 +3481,18 @@ dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
|
||||
dependencies = [
|
||||
"form_urlencoded",
|
||||
"itoa",
|
||||
"ryu",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.21.0"
|
||||
@ -3273,7 +3563,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@ -3583,6 +3873,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"heck 0.5.0",
|
||||
"http",
|
||||
"image",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"log",
|
||||
@ -3596,7 +3887,7 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"plist",
|
||||
"raw-window-handle",
|
||||
"reqwest",
|
||||
"reqwest 0.13.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
@ -3737,6 +4028,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 0.9.4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_repr",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-opener"
|
||||
version = "2.5.4"
|
||||
@ -3785,7 +4095,7 @@ dependencies = [
|
||||
"minisign-verify",
|
||||
"osakit",
|
||||
"percent-encoding",
|
||||
"reqwest",
|
||||
"reqwest 0.13.4",
|
||||
"rustls",
|
||||
"semver",
|
||||
"serde",
|
||||
@ -3902,6 +4212,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"
|
||||
@ -4549,6 +4870,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-time"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web_atoms"
|
||||
version = "0.2.5"
|
||||
@ -4614,6 +4945,15 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webview2-com"
|
||||
version = "0.38.2"
|
||||
@ -5289,6 +5629,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"
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
[package]
|
||||
name = "exbyte-depot"
|
||||
version = "0.2.2"
|
||||
description = "A Tauri App"
|
||||
authors = ["you"]
|
||||
version = "0.4.2"
|
||||
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,16 @@ 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"
|
||||
# backend-side HTTPS for the plugin registry (webview fetch is blocked by CORS)
|
||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] }
|
||||
|
||||
# Auto-updater (desktop only — checks Gitea Releases for a signed new version)
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
|
||||
@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 4.4 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 14 KiB |
BIN
src-tauri/icons/app-icon-src.png
Normal file
|
After Width: | Height: | Size: 59 KiB |
|
Before Width: | Height: | Size: 32 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 820 B After Width: | Height: | Size: 970 B |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 112 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 12 KiB |
2448
src-tauri/src/lib.rs
@ -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.4.2",
|
||||
"identifier": "com.bonchellon.exbyte-depot",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
@ -23,7 +24,8 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
"csp": "default-src 'self'; script-src 'self' blob:; 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 https://gitea.exbytestudios.com; worker-src 'self' blob:; object-src 'none'; base-uri 'self'",
|
||||
"devCsp": "default-src 'self'; script-src 'self' 'unsafe-inline' blob:; 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 https://gitea.exbytestudios.com; worker-src 'self' blob:; object-src 'none'; base-uri 'self'"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
|
||||
471
src/App.css
@ -9,6 +9,7 @@
|
||||
--bar:rgba(255,255,255,.015);--shadow:0 40px 90px -30px rgba(0,0,0,.7);
|
||||
--sunk:rgba(0,0,0,.15);--chip:rgba(0,0,0,.4);--hover:rgba(255,255,255,.05);
|
||||
--diff-add-c:#a8ecca;--diff-del-c:#f3a9b8;
|
||||
--logo-bg:#0c0e1a;--logo-ring:rgba(124,110,246,.28);
|
||||
--font:"Segoe UI Variable Text","Segoe UI",-apple-system,BlinkMacSystemFont,system-ui,"Helvetica Neue",Arial,sans-serif;
|
||||
--mono:"Cascadia Code","JetBrains Mono",ui-monospace,"SF Mono",Consolas,monospace;
|
||||
}
|
||||
@ -23,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}
|
||||
@ -81,7 +94,11 @@ button{font-family:var(--font)}
|
||||
.tgroup{display:flex;min-width:0;position:relative}
|
||||
.tgroup .tzone{flex:1;min-width:0}
|
||||
.tgroup .tzone:last-child{flex:0 0 var(--right-zone-w,220px);min-width:160px;border-right:none}
|
||||
.tzone:last-child{border-right:none}.tzone:hover{background:rgba(124,110,246,.06)}
|
||||
.tzone:last-child{border-right:none}
|
||||
.tzone:hover{background:var(--hover)}
|
||||
/* keep the labels/icon high-contrast on hover in every theme (fixes greyed-out text) */
|
||||
.tzone:hover .val{color:var(--txt)}.tzone:hover .lbl,.tzone:hover .sub{color:var(--muted)}
|
||||
.sync:hover .ic{color:var(--accent-2)}.sync:hover .val{color:var(--txt)}.sync:hover .sub{color:var(--muted)}
|
||||
|
||||
/* draggable panel dividers */
|
||||
.vhandle{position:absolute;top:0;bottom:0;width:9px;cursor:col-resize;z-index:8}
|
||||
@ -103,12 +120,21 @@ body.resizing{cursor:col-resize!important;user-select:none}
|
||||
.left{border-right:1px solid var(--border);display:flex;flex-direction:column;min-height:0;overflow:hidden}
|
||||
.right{display:flex;flex-direction:column;min-height:0}
|
||||
|
||||
.tabs{display:flex;height:46px;flex:0 0 46px;border-bottom:1px solid var(--border)}
|
||||
.tabs{display:flex;height:46px;flex:0 0 46px;border-bottom:1px solid var(--border);position:relative}
|
||||
.tab{flex:1;display:flex;align-items:center;justify-content:center;gap:8px;font-size:13px;font-weight:600;
|
||||
color:var(--muted);cursor:pointer;position:relative;transition:color .15s;background:none;border:none}
|
||||
color:var(--muted);cursor:pointer;position:relative;transition:color .2s;background:none;border:none}
|
||||
.tab:hover{color:var(--txt)}.tab.on{color:var(--txt)}
|
||||
.tab.on::after{content:"";position:absolute;bottom:-1px;left:16px;right:16px;height:2px;
|
||||
background:linear-gradient(90deg,var(--accent),var(--accent-2));border-radius:2px;box-shadow:0 0 10px rgba(124,110,246,.6)}
|
||||
.tab:focus{outline:none}
|
||||
.tab:focus-visible{outline:none;box-shadow:inset 0 0 0 2px rgba(124,110,246,.35)}
|
||||
.tab:active{transform:scale(.97)}
|
||||
/* sliding underline that glides between the two tabs */
|
||||
.tab-slider{position:absolute;bottom:0;height:2px;width:calc(50% - 32px);border-radius:2px;pointer-events:none;
|
||||
background:linear-gradient(90deg,var(--accent),var(--accent-2));box-shadow:0 0 10px rgba(124,110,246,.6);
|
||||
transition:left .34s cubic-bezier(.5,.02,.15,1)}
|
||||
/* content fades + rises slightly when the tab changes */
|
||||
.tabpane{flex:1;min-height:0;display:flex;flex-direction:column;animation:tabin .3s cubic-bezier(.4,0,.2,1)}
|
||||
@keyframes tabin{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}
|
||||
@media (prefers-reduced-motion:reduce){.tab-slider{transition:none}.tabpane{animation:none}}
|
||||
.badge{font-size:11px;font-weight:600;color:var(--muted);background:var(--panel-3);padding:1px 8px;border-radius:10px;font-variant-numeric:tabular-nums}
|
||||
.tab.on .badge{color:var(--accent-2);background:rgba(124,110,246,.16)}
|
||||
|
||||
@ -166,7 +192,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)}
|
||||
@ -222,6 +248,12 @@ body.resizing{cursor:col-resize!important;user-select:none}
|
||||
.scanbar{display:flex;align-items:center;gap:9px;margin:8px 10px 2px;padding:9px 12px;border-radius:11px;font-size:11.5px;line-height:1.4;
|
||||
color:var(--accent-2);background:rgba(124,110,246,.08);border:1px solid rgba(124,110,246,.22)}
|
||||
.scanbar .ldr{flex:0 0 auto}
|
||||
.scanbar > span{flex:1 1 auto;min-width:0}
|
||||
.scancount{font-weight:600;color:var(--txt);font-variant-numeric:tabular-nums}
|
||||
.scan-cancel{flex:0 0 auto;font-size:11px;font-weight:500;color:var(--muted);cursor:pointer;
|
||||
background:var(--panel);border:1px solid var(--border);border-radius:7px;padding:3px 11px;transition:.15s}
|
||||
.scan-cancel:hover{color:#fff;background:var(--danger,#e5484d);border-color:var(--danger,#e5484d)}
|
||||
.empty .scancount{color:var(--accent-2)}
|
||||
|
||||
/* submit progress modal */
|
||||
.push-modal{width:380px;background:var(--elevated);border:1px solid var(--border);border-radius:16px;padding:24px 26px;box-shadow:var(--shadow);text-align:center}
|
||||
@ -250,6 +282,10 @@ body.resizing{cursor:col-resize!important;user-select:none}
|
||||
.upd-ic{width:34px;height:34px;flex:0 0 auto;border-radius:10px;display:grid;place-items:center;color:#fff;
|
||||
background:linear-gradient(145deg,var(--accent-2),var(--accent-deep));box-shadow:0 0 18px rgba(124,110,246,.5)}
|
||||
.upd-ic svg{width:18px;height:18px}
|
||||
.upd.sm{width:auto;max-width:340px;padding:11px 14px}
|
||||
.upd.sm .upd-ic{width:28px;height:28px}
|
||||
.upd.sm.ok{cursor:pointer}
|
||||
.upd-ic.ok{background:linear-gradient(145deg,var(--add),#1f9f6e);box-shadow:0 0 16px rgba(62,207,142,.45)}
|
||||
.upd-ttl{display:flex;flex-direction:column;gap:1px;min-width:0}
|
||||
.upd-ttl b{font-size:13.5px}
|
||||
.upd-ttl span{font-size:11.5px;color:var(--faint);font-variant-numeric:tabular-nums}
|
||||
@ -338,6 +374,12 @@ body.resizing{cursor:col-resize!important;user-select:none}
|
||||
color:var(--muted);border-radius:7px;padding:3px 10px;cursor:pointer;font-size:11.5px}
|
||||
.stlog:hover{color:var(--txt);border-color:var(--accent)}
|
||||
.stlog svg{width:13px;height:13px}
|
||||
.stlog.icon{padding:3px 7px}
|
||||
.stupd{position:relative}
|
||||
.stupd.has{color:var(--accent);border-color:var(--accent)}
|
||||
.stupd.busy{color:var(--accent-2)}
|
||||
.stupd[disabled]{cursor:default;opacity:.85}
|
||||
.stupd-dot{position:absolute;top:2px;right:2px;width:6px;height:6px;border-radius:50%;background:var(--accent);box-shadow:0 0 0 2px var(--panel)}
|
||||
.logpeek{position:absolute;left:8px;bottom:26px;width:520px;max-width:calc(100vw - 20px);max-height:min(340px,60vh);overflow-y:auto;z-index:250;
|
||||
background:var(--elevated);border:1px solid var(--border);border-radius:11px 11px 0 0;padding:5px;box-shadow:0 -14px 40px -12px rgba(0,0,0,.55)}
|
||||
.logpeek-h{position:sticky;top:0;padding:5px 9px 7px;font-size:10.5px;text-transform:uppercase;letter-spacing:.5px;color:var(--faint);
|
||||
@ -363,6 +405,41 @@ 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)}
|
||||
.docktab:active{transform:scale(.96)}
|
||||
/* dock content fades in when switching Log / Terminal / Unreal */
|
||||
.dockpane{flex:1;min-height:0;display:flex;flex-direction:column;animation:tabin .26s cubic-bezier(.4,0,.2,1)}
|
||||
@media (prefers-reduced-motion:reduce){.vpane,.dockpane{animation:none}}
|
||||
.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;
|
||||
@ -398,9 +475,41 @@ body.resizing{cursor:col-resize!important;user-select:none}
|
||||
.xfer-ttl b{font-size:14.5px}
|
||||
.xfer-ttl span{font-size:12px;color:var(--faint);font-variant-numeric:tabular-nums}
|
||||
.xfer-file{margin-top:14px;font-family:var(--mono);font-size:11.5px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.xfer-bar{margin-top:12px;height:6px;border-radius:4px;background:var(--panel-3);overflow:hidden;position:relative}
|
||||
.xfer-actions{margin-left:auto;flex:0 0 auto;align-self:flex-start;display:flex;align-items:center;gap:8px}
|
||||
.xfer-cancel{flex:0 0 auto;font-size:11.5px;font-weight:600;cursor:pointer;
|
||||
background:var(--panel-2);border:1px solid var(--border);color:var(--muted);border-radius:8px;padding:5px 11px;transition:.15s}
|
||||
.xfer-cancel:hover:not(:disabled){color:#fff;background:var(--danger,#e5484d);border-color:var(--danger,#e5484d)}
|
||||
.xfer-cancel:disabled{opacity:.55;cursor:default}
|
||||
.xfer-min{flex:0 0 auto;width:26px;height:26px;display:grid;place-items:center;cursor:pointer;
|
||||
background:var(--panel-2);border:1px solid var(--border);color:var(--muted);border-radius:8px;transition:.15s}
|
||||
.xfer-min:hover{color:var(--txt);border-color:var(--accent)}
|
||||
.xfer-min svg{width:15px;height:15px}
|
||||
/* minimized transfer chip (bottom-right; sits above the build chip if both show) */
|
||||
.xferchip{position:fixed;right:18px;bottom:18px;z-index:361;display:flex;align-items:center;gap:11px;
|
||||
padding:9px 11px 9px 12px;border-radius:12px;cursor:pointer;background:var(--elevated);border:1px solid var(--border);
|
||||
box-shadow:0 16px 40px -14px rgba(0,0,0,.55);animation:updin .25s ease;min-width:210px;max-width:320px;overflow:hidden}
|
||||
.xferchip.up{border-color:rgba(124,110,246,.5)}
|
||||
.xferchip.down{border-color:rgba(62,207,142,.5)}
|
||||
.xferchip:hover{filter:brightness(1.06)}
|
||||
.xc-ic{flex:0 0 auto;display:grid;place-items:center}
|
||||
.xc-txt{display:flex;flex-direction:column;gap:1px;min-width:0;flex:1}
|
||||
.xc-txt b{font-size:12.5px;font-weight:600}
|
||||
.xc-txt span{font-size:11px;color:var(--faint);font-variant-numeric:tabular-nums;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.xc-cancel{flex:0 0 auto;width:22px;height:22px;border:none;background:none;color:var(--faint);border-radius:6px;cursor:pointer;display:grid;place-items:center}
|
||||
.xc-cancel:hover:not(:disabled){background:var(--danger,#e5484d);color:#fff}
|
||||
.xc-cancel:disabled{opacity:.5;cursor:default}
|
||||
.xc-bar{position:absolute;left:0;bottom:0;height:3px;width:100%;background:transparent}
|
||||
.xc-bar span{display:block;height:100%;background:linear-gradient(90deg,var(--accent-2),var(--accent-deep));transition:width .25s ease}
|
||||
.xfer-bar{margin-top:14px;height:6px;border-radius:4px;background:var(--panel-3);overflow:hidden;position:relative}
|
||||
.xfer-bar span{position:absolute;top:0;left:-35%;width:35%;height:100%;border-radius:4px;
|
||||
background:linear-gradient(90deg,transparent,var(--accent),transparent);animation:shim 1.1s ease-in-out infinite}
|
||||
/* determinate (known total): solid fill sized by inline width, no shimmer */
|
||||
.xfer-bar.det span{position:absolute;left:0;top:0;width:0;animation:none;
|
||||
background:linear-gradient(90deg,var(--accent-2),var(--accent-deep));transition:width .25s ease;box-shadow:0 0 10px rgba(124,110,246,.55)}
|
||||
.xfer-log{margin-top:12px;height:132px;overflow-y:auto;overflow-x:hidden;border:1px solid var(--border);border-radius:9px;
|
||||
background:var(--panel);padding:7px 10px;font-family:var(--mono);font-size:10.5px;line-height:1.55;color:var(--muted)}
|
||||
.xfer-log div{white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.xfer-log div:last-child{color:var(--txt)}
|
||||
|
||||
/* MSBuild output overlay */
|
||||
.build{width:660px;max-width:calc(100vw - 40px);height:min(70vh,560px);display:flex;flex-direction:column;
|
||||
@ -445,6 +554,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 +575,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 +593,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 +635,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 +664,131 @@ 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)}
|
||||
/* workspace View include/exclude tree (P4V-style) */
|
||||
.vt-modes{margin-left:auto;display:flex;gap:2px;background:var(--panel-3);border:1px solid var(--border);border-radius:9px;padding:2px}
|
||||
.vt-modes button{width:28px;height:24px;display:grid;place-items:center;border:none;background:none;color:var(--faint);border-radius:7px;cursor:pointer;transition:.15s}
|
||||
.vt-modes button svg{width:15px;height:15px}
|
||||
.vt-modes button:hover{color:var(--txt)}
|
||||
.vt-modes button.on{background:var(--elevated);color:var(--accent-2)}
|
||||
.vt-tree{flex:1;min-height:0;overflow:auto;background:var(--elevated);padding:6px 4px}
|
||||
.vt-node{user-select:none}
|
||||
.vt-row{display:flex;align-items:center;gap:7px;height:26px;border-radius:7px;padding-right:8px;cursor:default}
|
||||
.vt-row:hover{background:#ffffff08}
|
||||
.vt-chev{width:16px;height:16px;flex:0 0 auto;display:grid;place-items:center;color:var(--faint);cursor:pointer}
|
||||
.vt-chev svg{width:13px;height:13px;transition:transform .12s ease}
|
||||
.vt-chk{width:16px;height:16px;flex:0 0 auto;border-radius:4px;border:1.5px solid var(--border);display:grid;place-items:center;cursor:pointer;transition:.12s;background:var(--panel)}
|
||||
.vt-chk:hover{border-color:var(--accent)}
|
||||
.vt-chk.on{background:var(--accent);border-color:var(--accent)}
|
||||
.vt-chk.mixed{background:var(--faint);border-color:var(--faint)}
|
||||
.vt-chk svg{width:12px;height:12px}
|
||||
.vt-ic{width:16px;height:16px;flex:0 0 auto;display:grid;place-items:center;color:var(--accent-2)}
|
||||
.vt-ic svg{width:15px;height:15px}
|
||||
.vt-name{font-size:13px;color:var(--txt);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.vt-loading,.vt-empty{height:24px;display:flex;align-items:center;gap:7px;font-size:11.5px;color:var(--faint)}
|
||||
/* themes modal */
|
||||
.th-grid{flex:1;min-height:0;overflow:auto;display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:12px;padding:16px}
|
||||
.th-card{position:relative;border:1.5px solid var(--border);border-radius:13px;overflow:hidden;cursor:pointer;background:var(--panel);transition:.15s}
|
||||
.th-card:hover{border-color:var(--accent)}
|
||||
.th-card.on{border-color:var(--accent);box-shadow:0 0 0 3px rgba(124,110,246,.2)}
|
||||
.th-swatch{height:64px;display:flex;align-items:center;gap:7px;padding:0 14px}
|
||||
.th-swatch span{width:20px;height:20px;border-radius:50%;box-shadow:0 2px 6px rgba(0,0,0,.35)}
|
||||
.th-meta{padding:9px 12px 11px}
|
||||
.th-meta b{display:block;font-size:13px;font-weight:600}
|
||||
.th-meta span{font-size:11px;color:var(--faint)}
|
||||
.th-on{position:absolute;top:8px;right:8px;width:22px;height:22px;border-radius:50%;background:var(--accent);color:#fff;display:grid;place-items:center}
|
||||
.th-on svg{width:13px;height:13px}
|
||||
.th-actions{position:absolute;top:7px;right:7px;display:none;gap:4px}
|
||||
.th-card:hover .th-actions{display:flex}
|
||||
.th-actions button{width:24px;height:24px;border:none;border-radius:7px;background:var(--chip);color:var(--txt);cursor:pointer;display:grid;place-items:center;backdrop-filter:blur(4px)}
|
||||
.th-actions button svg{width:13px;height:13px}
|
||||
.th-actions button:hover{background:var(--accent);color:#fff}
|
||||
.th-actions button.danger:hover{background:var(--del)}
|
||||
.th-edit{flex:1;min-height:0;overflow:auto;padding:16px}
|
||||
.th-edit-row{display:flex;align-items:center;gap:12px;margin-bottom:18px;flex-wrap:wrap}
|
||||
.th-edit-row label{font-size:12px;color:var(--muted);font-weight:600}
|
||||
.th-edit-row input{flex:1;min-width:160px;background:var(--input-bg);border:1px solid var(--border);border-radius:9px;padding:9px 12px;color:var(--txt);font-size:14px;outline:none}
|
||||
.th-edit-row input:focus{border-color:var(--accent)}
|
||||
.th-base{display:flex;gap:2px;background:var(--panel-3);border-radius:9px;padding:2px}
|
||||
.th-base button{display:flex;align-items:center;gap:5px;font-size:12px;font-weight:600;border:none;background:none;color:var(--muted);border-radius:7px;padding:7px 11px;cursor:pointer}
|
||||
.th-base button svg{width:14px;height:14px}
|
||||
.th-base button.on{background:var(--elevated);color:var(--accent-2)}
|
||||
.th-group{margin-bottom:16px}
|
||||
.th-group-h{font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--faint);font-weight:600;margin-bottom:9px}
|
||||
.th-tokens{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:9px}
|
||||
.th-token{display:flex;align-items:center;gap:9px;font-size:12.5px;color:var(--txt);cursor:pointer}
|
||||
.th-token input[type=color]{width:30px;height:30px;flex:0 0 auto;border:1px solid var(--border);border-radius:8px;background:none;cursor:pointer;padding:2px}
|
||||
.th-token input[type=color]::-webkit-color-swatch{border:none;border-radius:6px}
|
||||
.th-token input[type=color]::-webkit-color-swatch-wrapper{padding:0}
|
||||
/* plugins manager */
|
||||
.pl-list{flex:1;min-height:0;overflow:auto;padding:14px;display:flex;flex-direction:column;gap:10px}
|
||||
.pl-official-h{font-size:11px;text-transform:uppercase;letter-spacing:.5px;color:var(--faint);font-weight:700;margin:8px 2px 0;padding-top:10px;border-top:1px solid var(--border-soft);display:flex;align-items:baseline;gap:8px}
|
||||
.pl-cat-sub{text-transform:none;letter-spacing:0;font-weight:500;color:var(--faint);opacity:.8}
|
||||
.pl-card{display:flex;gap:12px;align-items:flex-start;border:1px solid var(--border);border-radius:12px;padding:13px 15px;background:var(--panel);transition:.15s}
|
||||
.pl-card.off{opacity:.55}
|
||||
.pl-main{flex:1;min-width:0}
|
||||
.pl-top{display:flex;align-items:baseline;gap:9px}
|
||||
.pl-top b{font-size:14px;font-weight:650}
|
||||
.pl-ver{font-size:11px;color:var(--faint);font-variant-numeric:tabular-nums}
|
||||
.pl-desc{font-size:12.5px;color:var(--muted);margin-top:3px;line-height:1.45}
|
||||
.pl-meta{display:flex;flex-wrap:wrap;gap:6px;margin-top:9px}
|
||||
.pl-tag{font-size:11px;color:var(--muted);background:var(--panel-3);border-radius:6px;padding:2px 8px}
|
||||
.pl-perm{font-size:10.5px;font-weight:600;color:var(--accent-2);background:rgba(124,110,246,.12);border:1px solid rgba(124,110,246,.25);border-radius:6px;padding:2px 8px}
|
||||
.pl-actions{flex:0 0 auto;display:flex;align-items:center;gap:8px}
|
||||
.pl-toggle{width:38px;height:22px;border-radius:12px;border:none;background:var(--panel-3);cursor:pointer;position:relative;transition:.18s;padding:0}
|
||||
.pl-toggle span{position:absolute;top:3px;left:3px;width:16px;height:16px;border-radius:50%;background:var(--faint);transition:.18s}
|
||||
.pl-toggle.on{background:var(--accent)}
|
||||
.pl-toggle.on span{left:19px;background:#fff}
|
||||
.pl-del{width:30px;height:30px;border:none;background:none;color:var(--faint);border-radius:8px;cursor:pointer;display:grid;place-items:center}
|
||||
.pl-del svg{width:15px;height:15px}
|
||||
.pl-del:hover{background:rgba(242,99,126,.14);color:var(--del)}
|
||||
.plugin-view{flex:1;min-height:0;min-height:340px;overflow:auto;padding:16px;color:var(--txt);font-size:13.5px}
|
||||
/* settings — two-pane (left nav + content) */
|
||||
.settings-modal{width:min(860px,94vw);height:min(620px,88vh);display:flex;background:var(--elevated);border:1px solid var(--border);border-radius:16px;box-shadow:var(--shadow);overflow:hidden;animation:updin .2s ease}
|
||||
.set-nav{flex:0 0 210px;display:flex;flex-direction:column;gap:2px;padding:14px 10px;background:var(--panel);border-right:1px solid var(--border-soft)}
|
||||
.set-nav-h{display:flex;align-items:center;gap:9px;padding:6px 10px 14px;font-size:14px;font-weight:650}
|
||||
.set-nav-logo{display:grid;place-items:center;color:var(--accent-2)}
|
||||
.set-nav-logo svg{width:18px;height:18px}
|
||||
.set-nav-item{display:flex;align-items:center;gap:10px;padding:9px 11px;border:none;background:none;color:var(--muted);font-size:13.5px;font-weight:500;font-family:var(--font);border-radius:9px;cursor:pointer;text-align:left;transition:.12s}
|
||||
.set-nav-item .sni-ic{display:grid;place-items:center;flex:0 0 auto}
|
||||
.set-nav-item svg{width:16px;height:16px}
|
||||
.set-nav-item:hover{background:var(--hover);color:var(--txt)}
|
||||
.set-nav-item.on{background:var(--accent);color:#fff}
|
||||
.set-nav-item.danger{color:var(--del)}
|
||||
.set-nav-item.danger:hover{background:rgba(242,99,126,.14)}
|
||||
.set-nav-sp{flex:1}
|
||||
.set-main{flex:1;min-width:0;display:flex;flex-direction:column}
|
||||
.set-main-h{display:flex;align-items:center;padding:16px 18px 12px;border-bottom:1px solid var(--border-soft)}
|
||||
.set-main-h h3{font-size:16px;font-weight:650;flex:1}
|
||||
.set-main .info-body{flex:1;min-height:0;padding:16px 20px}
|
||||
.set-panel{flex:1;min-height:0;display:flex;flex-direction:column;overflow:hidden}
|
||||
.set-sep{height:1px;background:var(--border-soft);margin:14px 0}
|
||||
/* 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}
|
||||
@ -551,7 +803,7 @@ body.resizing{cursor:col-resize!important;user-select:none}
|
||||
.diff::-webkit-scrollbar{width:10px}.diff::-webkit-scrollbar-thumb{background:var(--panel-3);border-radius:8px}
|
||||
|
||||
/* history */
|
||||
.hrow{display:flex;gap:12px;align-items:flex-start;padding:12px 16px;border-bottom:1px solid var(--border-soft)}
|
||||
.hrow{display:flex;gap:12px;align-items:flex-start;padding:12px 16px;border-bottom:1px solid var(--border-soft);position:relative}
|
||||
.hrow.click{cursor:pointer}
|
||||
.hrow:hover{background:var(--hover)}
|
||||
.hrow.sel{background:rgba(124,110,246,.12);box-shadow:inset 2px 0 0 var(--accent)}
|
||||
@ -567,18 +819,54 @@ 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;overflow-y:hidden}
|
||||
.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;transition:background .22s ease,border-color .22s ease,color .18s ease}
|
||||
.vtab:active{transform:scale(.97)}
|
||||
.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;animation:tabin .28s cubic-bezier(.4,0,.2,1)}
|
||||
.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)}
|
||||
.ft-new{display:flex;align-items:center;gap:5px;font-family:var(--font);font-size:11.5px;font-weight:600;color:var(--accent-2);
|
||||
background:rgba(124,110,246,.1);border:1px solid rgba(124,110,246,.24);border-radius:8px;padding:4px 10px;cursor:pointer;flex:0 0 auto;transition:.14s}
|
||||
.ft-new:hover{background:rgba(124,110,246,.18);border-color:rgba(124,110,246,.4)}
|
||||
.ft-new svg{width:13px;height:13px}
|
||||
/* folder properties dialog */
|
||||
.fp-body{padding:18px 20px}
|
||||
.fp-name{display:flex;align-items:center;gap:10px;font-size:15px;margin-bottom:16px}
|
||||
.fp-name svg{width:20px;height:20px;color:var(--accent-2);flex:0 0 auto}
|
||||
.fp-name b{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.fp-grid{display:grid;grid-template-columns:auto 1fr;gap:10px 16px;font-size:13px}
|
||||
.fp-grid .k{color:var(--faint)}
|
||||
.fp-grid .v{color:var(--txt);min-width:0;overflow:hidden;text-overflow:ellipsis;font-variant-numeric:tabular-nums}
|
||||
.fp-grid .v.mono{font-family:var(--mono);font-size:12px;word-break:break-all;white-space:normal}
|
||||
.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}
|
||||
.hnew{position:absolute;top:50%;right:14px;transform:translateY(-50%);padding:3px 10px;border-radius:7px;font-size:11px;font-weight:800;letter-spacing:.7px;color:#fff;pointer-events:none;
|
||||
background:linear-gradient(135deg,var(--accent-2),var(--accent-deep));box-shadow:0 0 0 rgba(124,110,246,.5);animation:newpulse 2.2s ease-in-out infinite}
|
||||
@keyframes newpulse{0%,100%{box-shadow:0 0 0 0 rgba(124,110,246,.45)}50%{box-shadow:0 0 0 4px rgba(124,110,246,0)}}
|
||||
|
||||
/* 3d viewer toolbar */
|
||||
.v3d-tools{position:absolute;top:12px;right:12px;display:flex;gap:6px;z-index:2}
|
||||
@ -603,8 +891,9 @@ body.resizing{cursor:col-resize!important;user-select:none}
|
||||
.picker-head{padding:18px 20px 14px;border-bottom:1px solid var(--border-soft);display:flex;align-items:center;gap:10px}
|
||||
.picker-head h3{font-size:15px;font-weight:700}
|
||||
.picker-head > svg{width:20px;height:20px;flex:0 0 auto;color:var(--accent-2)}
|
||||
.picker-head .x{margin-left:auto;width:28px;height:28px;border:none;background:var(--panel);border-radius:8px;color:var(--muted);cursor:pointer;display:grid;place-items:center}
|
||||
.picker-head .x:hover{color:var(--txt)}
|
||||
.picker-head .x,.set-main-h .x{margin-left:auto;width:28px;height:28px;border:none;background:var(--panel);border-radius:8px;color:var(--muted);cursor:pointer;display:grid;place-items:center;transition:.15s}
|
||||
.picker-head .x:hover,.set-main-h .x:hover{color:var(--txt);background:var(--hover)}
|
||||
.picker-head .x svg,.set-main-h .x svg{width:14px;height:14px}
|
||||
.picker-list{overflow-y:auto;padding:8px}
|
||||
.picker-item{display:flex;align-items:center;gap:12px;padding:11px 12px;border-radius:11px;cursor:pointer;transition:background .12s}
|
||||
.picker-item:hover{background:var(--hover)}
|
||||
@ -614,6 +903,13 @@ body.resizing{cursor:col-resize!important;user-select:none}
|
||||
.picker-item .pi-body{display:flex;flex-direction:column;gap:2px;min-width:0}
|
||||
.picker-item .pi-name{font-size:13.5px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.picker-item .pi-sub{font-size:11.5px;color:var(--faint);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
/* recently-used working folders */
|
||||
.pick-recent{border-bottom:1px solid var(--border-soft);margin-bottom:6px;padding-bottom:6px}
|
||||
.pick-recent-h{font-size:10.5px;letter-spacing:.09em;text-transform:uppercase;color:var(--faint);padding:4px 12px 6px;font-weight:600}
|
||||
.picker-item .pi-ic.clock,.picker-item .pi-ic.up{color:var(--muted);background:var(--panel-3)}
|
||||
/* "local only, not in depot yet" tag */
|
||||
.local-tag{margin-left:8px;font-size:9.5px;font-weight:700;letter-spacing:.05em;text-transform:uppercase;color:var(--warn);
|
||||
background:rgba(232,176,75,.14);border:1px solid rgba(232,176,75,.32);border-radius:6px;padding:1px 6px;vertical-align:middle}
|
||||
.picker-foot{padding:12px 20px;border-top:1px solid var(--border-soft);font-size:12px;color:var(--faint)}
|
||||
.info-body{padding:20px;font-size:13px;line-height:1.6;color:var(--muted)}
|
||||
.info-body b{color:var(--txt)}
|
||||
@ -633,7 +929,8 @@ body.resizing{cursor:col-resize!important;user-select:none}
|
||||
.modal-card h3{font-size:17px;font-weight:700;margin-bottom:8px}
|
||||
.modal-card p{font-size:13px;line-height:1.55;color:var(--muted);white-space:pre-wrap;margin-bottom:22px}
|
||||
.modal-actions{display:flex;gap:10px}
|
||||
.mbtn{flex:1;border:none;border-radius:11px;padding:11px;font-size:13.5px;font-weight:600;cursor:pointer;font-family:var(--font);transition:filter .15s,background .15s}
|
||||
.mbtn{flex:1;display:inline-flex;align-items:center;justify-content:center;gap:8px;border:none;border-radius:11px;padding:11px;font-size:13.5px;font-weight:600;cursor:pointer;font-family:var(--font);transition:filter .15s,background .15s}
|
||||
.mbtn svg{width:15px;height:15px;flex:0 0 auto}
|
||||
.mbtn.ghost{background:var(--panel);border:1px solid var(--border);color:var(--txt)}
|
||||
.mbtn.ghost:hover{background:var(--hover)}
|
||||
.mbtn.primary{color:#fff;background:linear-gradient(135deg,var(--accent-2),var(--accent-deep));box-shadow:0 8px 22px -8px rgba(124,110,246,.7)}
|
||||
@ -650,11 +947,17 @@ body.resizing{cursor:col-resize!important;user-select:none}
|
||||
.dropinner svg{width:44px;height:44px}
|
||||
.dropinner span{font-size:15px;font-weight:600;color:var(--txt)}
|
||||
|
||||
/* re-auth: who we're signing back in as */
|
||||
.reauth-who{display:flex;flex-wrap:wrap;gap:6px;margin:2px 0 14px}
|
||||
.reauth-who span{font-family:var(--mono);font-size:11px;color:var(--muted);
|
||||
background:var(--panel-3);border:1px solid var(--border);border-radius:6px;padding:3px 8px}
|
||||
|
||||
/* toast */
|
||||
.toast{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);z-index:200;max-width:72%;
|
||||
background:var(--elevated);border:1px solid var(--border);color:var(--txt);
|
||||
padding:11px 18px;border-radius:12px;font-size:12.5px;box-shadow:0 20px 50px -15px rgba(0,0,0,.6);
|
||||
animation:toastin .25s ease;white-space:pre-wrap;text-align:center}
|
||||
animation:toastin .25s ease;white-space:pre-wrap;text-align:center;
|
||||
max-height:30vh;overflow-y:auto;overflow-wrap:anywhere}
|
||||
.toast.err{border-color:rgba(242,99,126,.4);color:var(--del)}
|
||||
@keyframes toastin{from{opacity:0;transform:translate(-50%,10px)}to{opacity:1;transform:translate(-50%,0)}}
|
||||
|
||||
@ -673,6 +976,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 +1087,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)}
|
||||
@ -705,7 +1134,9 @@ body.resizing{cursor:col-resize!important;user-select:none}
|
||||
.ur-actd{width:8px;height:8px;border-radius:50%;flex:0 0 auto;background:var(--faint)}
|
||||
.ur-actd.active{background:#3ecb8f;box-shadow:0 0 0 3px rgba(62,203,143,.18)}
|
||||
.ur-actd.recent{background:#f4c04e}.ur-actd.idle{background:#4b4b58}
|
||||
.ur-empty{padding:16px;font-size:12px;color:var(--faint);text-align:center}
|
||||
.ur-empty{padding:16px;font-size:12px;color:var(--faint);text-align:center;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;white-space:pre-line}
|
||||
.ur-empty svg{width:34px;height:34px;opacity:.35;flex:0 0 auto}
|
||||
.viewer-msg svg{width:36px;height:36px;opacity:.4;flex:0 0 auto}
|
||||
.ur-detail{flex:1;display:flex;flex-direction:column;min-width:0;overflow-y:auto}
|
||||
.ur-dhead{display:flex;align-items:center;gap:14px;padding:18px 20px 14px;border-bottom:1px solid var(--border-soft)}
|
||||
.ur-dinfo{min-width:0}
|
||||
|
||||
2889
src/App.tsx
@ -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>
|
||||
|
||||
@ -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 (
|
||||
|
||||
103
src/Updater.tsx
@ -1,35 +1,44 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { check, type Update } from "@tauri-apps/plugin-updater";
|
||||
import { relaunch } from "@tauri-apps/plugin-process";
|
||||
import { t } from "./i18n";
|
||||
|
||||
type Phase = "idle" | "available" | "downloading" | "done" | "error";
|
||||
export type UpdPhase = "idle" | "checking" | "available" | "uptodate" | "downloading" | "done" | "error";
|
||||
|
||||
// Non-intrusive update prompt: quietly checks the Gitea Releases endpoint a few
|
||||
// seconds after launch. If a newer signed version exists, slides a small card into
|
||||
// the bottom-right corner. The user chooses when to install — nothing is forced.
|
||||
export default function UpdateBanner() {
|
||||
export interface UpdaterState {
|
||||
phase: UpdPhase;
|
||||
pct: number;
|
||||
err: string;
|
||||
version?: string;
|
||||
check: () => void; // manual check
|
||||
install: () => void; // download + install the pending update
|
||||
}
|
||||
|
||||
// Update logic as a hook. Silently checks the Gitea Releases endpoint a few
|
||||
// seconds after mount, and again on demand (manual). The UI is just a single
|
||||
// download icon in the status bar (see Workbench) — no floating card.
|
||||
export function useUpdater(): UpdaterState {
|
||||
const [update, setUpdate] = useState<Update | null>(null);
|
||||
const [phase, setPhase] = useState<Phase>("idle");
|
||||
const [phase, setPhase] = useState<UpdPhase>("idle");
|
||||
const [pct, setPct] = useState(0);
|
||||
const [err, setErr] = useState("");
|
||||
const [hidden, setHidden] = useState(false);
|
||||
const busyRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
const t = setTimeout(async () => {
|
||||
try {
|
||||
const u = await check();
|
||||
if (live && u) { setUpdate(u); setPhase("available"); }
|
||||
} catch {
|
||||
// offline, or no release published yet — stay silent, never nag
|
||||
}
|
||||
}, 4000);
|
||||
return () => { live = false; clearTimeout(t); };
|
||||
}, []);
|
||||
async function runCheck(manual: boolean) {
|
||||
if (busyRef.current || phase === "downloading") return;
|
||||
busyRef.current = true;
|
||||
if (manual) setPhase("checking");
|
||||
try {
|
||||
const u = await check();
|
||||
if (u) { setUpdate(u); setPhase("available"); }
|
||||
else { setPhase(manual ? "uptodate" : "idle"); }
|
||||
} catch (e) {
|
||||
setErr(String(e));
|
||||
if (manual) setPhase("error");
|
||||
} finally { busyRef.current = false; }
|
||||
}
|
||||
|
||||
async function install() {
|
||||
if (!update) return;
|
||||
if (!update) { void runCheck(true); return; }
|
||||
setPhase("downloading"); setPct(0);
|
||||
let total = 0, got = 0;
|
||||
try {
|
||||
@ -43,42 +52,20 @@ export default function UpdateBanner() {
|
||||
} catch (e) { setErr(String(e)); setPhase("error"); }
|
||||
}
|
||||
|
||||
if (hidden || phase === "idle" || !update) return null;
|
||||
// initial silent check + listen for manual "Updates" clicks (kept for compatibility)
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => runCheck(false), 4000);
|
||||
const onManual = () => runCheck(true);
|
||||
window.addEventListener("exd-check-update", onManual);
|
||||
return () => { clearTimeout(id); window.removeEventListener("exd-check-update", onManual); };
|
||||
}, []); // eslint-disable-line
|
||||
|
||||
return (
|
||||
<div className="upd">
|
||||
<div className="upd-head">
|
||||
<span className="upd-ic">
|
||||
<svg viewBox="0 0 24 24" fill="none"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg>
|
||||
</span>
|
||||
<div className="upd-ttl">
|
||||
<b>{t("Update available")}</b>
|
||||
<span>Exbyte Depot v{update.version}</span>
|
||||
</div>
|
||||
{(phase === "available" || phase === "error") && (
|
||||
<button className="upd-x" onClick={() => setHidden(true)} title={t("Later")}>
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="none"><path d="M6 6l12 12M18 6L6 18" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" /></svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
// let the transient "checked, up to date" / "error" state fade back to idle
|
||||
useEffect(() => {
|
||||
if (phase !== "uptodate" && phase !== "error") return;
|
||||
const id = setTimeout(() => setPhase("idle"), 3500);
|
||||
return () => clearTimeout(id);
|
||||
}, [phase]);
|
||||
|
||||
{update.body?.trim() && phase === "available" && <div className="upd-notes">{update.body.trim()}</div>}
|
||||
|
||||
{phase === "downloading" && (
|
||||
<div className="upd-prog">
|
||||
<div className="upd-track"><div className="upd-fill" style={{ width: `${pct}%` }} /></div>
|
||||
<span>{t("Downloading… {pct}%", { pct })}</span>
|
||||
</div>
|
||||
)}
|
||||
{phase === "done" && <div className="upd-notes">{t("Installed — restarting…")}</div>}
|
||||
{phase === "error" && <div className="upd-notes err">{err}</div>}
|
||||
|
||||
{phase === "available" && (
|
||||
<div className="upd-actions">
|
||||
<button className="upd-later" onClick={() => setHidden(true)}>{t("Later")}</button>
|
||||
<button className="upd-go" onClick={install}>{t("Update now")}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return { phase, pct, err, version: update?.version, check: () => runCheck(true), install };
|
||||
}
|
||||
|
||||
71
src/ai.ts
@ -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 (2–4 sentences, plain text)
|
||||
export async function aiExplainCode(name: string, code: string): Promise<string> {
|
||||
const key = await resolveKey();
|
||||
if (!key) throw new Error("No OpenRouter API key baked into this build");
|
||||
const snippet = code.slice(0, 14000); // cap the payload
|
||||
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", "X-Title": "Exbyte Depot" },
|
||||
body: JSON.stringify({
|
||||
model: DEFAULT_MODEL,
|
||||
max_tokens: 260,
|
||||
temperature: 0.2,
|
||||
messages: [
|
||||
{ role: "system", content: `You explain source files for a Perforce/Unreal C++ (and general game) project. Given one file, reply with a SHORT plain-text summary (2–4 sentences) of what it does and its role in the project. No markdown, no code fences, no headings, no bullet points — a single tight paragraph. Write the entire reply in ${langName()}.` },
|
||||
{ role: "user", content: `File: ${name}\n\n${snippet}` },
|
||||
],
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "");
|
||||
throw new Error(`OpenRouter ${res.status}: ${txt.slice(0, 180)}`);
|
||||
}
|
||||
const data = await res.json();
|
||||
const content: string = (data?.choices?.[0]?.message?.content || "").trim();
|
||||
if (!content) throw new Error("Empty AI response");
|
||||
return content;
|
||||
}
|
||||
|
||||
// --- persistent cache of AI code summaries (removed only via the × button) ---
|
||||
const EXPL_KEY = "exd-explain";
|
||||
type ExplEntry = { k: string; v: string };
|
||||
function loadExpl(): ExplEntry[] {
|
||||
try { const r = localStorage.getItem(EXPL_KEY); const l = r ? JSON.parse(r) : []; return Array.isArray(l) ? l : []; } catch { return []; }
|
||||
}
|
||||
function storeExpl(list: ExplEntry[]) { try { localStorage.setItem(EXPL_KEY, JSON.stringify(list)); } catch {} }
|
||||
export function getExplain(key: string): string | null {
|
||||
return loadExpl().find((e) => e.k === key)?.v ?? null;
|
||||
}
|
||||
export function saveExplain(key: string, text: string) {
|
||||
const list = loadExpl().filter((e) => e.k !== key);
|
||||
list.push({ k: key, v: text });
|
||||
storeExpl(list.slice(-80)); // keep the 80 most recent
|
||||
}
|
||||
export function dropExplain(key: string) {
|
||||
storeExpl(loadExpl().filter((e) => e.k !== key));
|
||||
}
|
||||
|
||||
// --- avatar helpers ---
|
||||
export function initials(name: string, user: string): string {
|
||||
const n = (name || "").trim();
|
||||
|
||||
374
src/i18n.ts
@ -73,8 +73,21 @@ const D: Record<string, Tr> = {
|
||||
"Build Solution (.sln)": { ru: "Собрать решение (.sln)", de: "Projektmappe bauen (.sln)", fr: "Compiler la solution (.sln)", es: "Compilar solución (.sln)" },
|
||||
"Build Solution — no .sln": { ru: "Собрать решение — нет .sln", de: "Projektmappe bauen — keine .sln", fr: "Compiler — aucune .sln", es: "Compilar — sin .sln" },
|
||||
"No .sln found in the working folder. Choose the project folder first.": { ru: "В рабочей папке нет .sln. Сначала выбери папку проекта.", de: "Keine .sln im Arbeitsordner. Wähle zuerst den Projektordner.", fr: "Aucune .sln dans le dossier de travail. Choisissez d’abord le dossier du projet.", es: "No hay .sln en la carpeta de trabajo. Elige primero la carpeta del proyecto." },
|
||||
"No Unreal project in the working folder. Choose the project folder first.": { ru: "В рабочей папке нет проекта Unreal. Сначала выбери папку проекта.", de: "Kein Unreal-Projekt im Arbeitsordner. Wähle zuerst den Projektordner.", fr: "Aucun projet Unreal dans le dossier de travail. Choisissez d’abord le dossier du projet.", es: "No hay proyecto Unreal en la carpeta de trabajo. Elige primero la carpeta del proyecto." },
|
||||
"Build": { ru: "Сборка", de: "Build", fr: "Compilation", es: "Compilación" },
|
||||
"AI": { ru: "ИИ", de: "KI", fr: "IA", es: "IA" },
|
||||
"Terminal": { ru: "Терминал", de: "Terminal", fr: "Terminal", es: "Terminal" },
|
||||
"Summary": { ru: "Описание", de: "Zusammenfassung", fr: "Résumé", es: "Resumen" },
|
||||
"AI summary — what this file does": { ru: "ИИ-описание — что делает этот файл", de: "KI-Zusammenfassung — was diese Datei tut", fr: "Résumé IA — ce que fait ce fichier", es: "Resumen con IA — qué hace este archivo" },
|
||||
"Reading the code…": { ru: "Читаю код…", de: "Code wird gelesen…", fr: "Lecture du code…", es: "Leyendo el código…" },
|
||||
"Dismiss": { ru: "Закрыть", de: "Schließen", fr: "Fermer", es: "Cerrar" },
|
||||
"Unreal": { ru: "Unreal", de: "Unreal", fr: "Unreal", es: "Unreal" },
|
||||
"Unreal Log": { ru: "Лог Unreal", de: "Unreal-Log", fr: "Journal Unreal", es: "Registro de Unreal" },
|
||||
"(no output)": { ru: "(нет вывода)", de: "(keine Ausgabe)", fr: "(aucune sortie)", es: "(sin salida)" },
|
||||
"running…": { ru: "выполняется…", de: "läuft…", fr: "en cours…", es: "ejecutando…" },
|
||||
"type a p4 command…": { ru: "введите команду p4…", de: "p4-Befehl eingeben…", fr: "tapez une commande p4…", es: "escribe un comando p4…" },
|
||||
"p4 console — type a command (e.g. opened, changes -m 5, info). ↑/↓ recalls history, Tab completes.": { ru: "Консоль p4 — введите команду (например: opened, changes -m 5, info). ↑/↓ — история, Tab — автодополнение.", de: "p4-Konsole — Befehl eingeben (z. B. opened, changes -m 5, info). ↑/↓ Verlauf, Tab vervollständigt.", fr: "Console p4 — tapez une commande (ex. opened, changes -m 5, info). ↑/↓ historique, Tab complète.", es: "Consola p4 — escribe un comando (p. ej. opened, changes -m 5, info). ↑/↓ historial, Tab autocompleta." },
|
||||
"No Unreal log yet. It shows here when the editor writes to Saved/Logs — i.e. while Unreal is running.": { ru: "Лога Unreal пока нет. Он появится здесь, когда редактор пишет в Saved/Logs — то есть пока Unreal запущен.", de: "Noch kein Unreal-Log. Es erscheint hier, sobald der Editor nach Saved/Logs schreibt — also während Unreal läuft.", fr: "Aucun journal Unreal pour l’instant. Il apparaît ici quand l’éditeur écrit dans Saved/Logs, c.-à-d. quand Unreal tourne.", es: "Aún no hay registro de Unreal. Aparece aquí cuando el editor escribe en Saved/Logs, es decir, mientras Unreal se ejecuta." },
|
||||
"AI commit messages": { ru: "ИИ-сообщения коммитов", de: "KI-Commit-Nachrichten", fr: "Messages de commit IA", es: "Mensajes de commit con IA" },
|
||||
"Draft a commit message with AI from the selected files": { ru: "Сгенерировать сообщение коммита из выбранных файлов через ИИ", de: "Commit-Nachricht per KI aus den gewählten Dateien entwerfen", fr: "Rédiger un message de commit par IA à partir des fichiers sélectionnés", es: "Redactar un mensaje de commit con IA a partir de los archivos seleccionados" },
|
||||
"Select some files first.": { ru: "Сначала выбери файлы.", de: "Wähle zuerst Dateien aus.", fr: "Sélectionnez d’abord des fichiers.", es: "Selecciona primero algunos archivos." },
|
||||
@ -136,6 +149,78 @@ const D: Record<string, Tr> = {
|
||||
"Save": { ru: "Сохранить", de: "Speichern", fr: "Enregistrer", es: "Guardar" },
|
||||
"Scanning disk for changes…": { ru: "Ищу изменения на диске…", de: "Suche Änderungen auf der Festplatte…", fr: "Recherche de modifications sur le disque…", es: "Buscando cambios en el disco…" },
|
||||
" for the whole workspace this is slow — pick a project folder above": { ru: " для всего workspace это долго — выбери рабочую папку проекта сверху", de: " für den ganzen Workspace ist das langsam — wähle oben einen Projektordner", fr: " pour tout le workspace c’est lent — choisissez un dossier de projet en haut", es: " para todo el workspace es lento — elige una carpeta de proyecto arriba" },
|
||||
"found {n} files": { ru: "найдено {n} файлов", de: "{n} Dateien gefunden", fr: "{n} fichiers trouvés", es: "{n} archivos encontrados" },
|
||||
"Committing…": { ru: "Коммичу…", de: "Übertrage…", fr: "Validation…", es: "Confirmando…" },
|
||||
"Cancelling…": { ru: "Отменяю…", de: "Breche ab…", fr: "Annulation…", es: "Cancelando…" },
|
||||
"Minimize — the transfer keeps running in the background": { ru: "Свернуть — передача продолжится в фоне", de: "Minimieren — die Übertragung läuft im Hintergrund weiter", fr: "Réduire — le transfert continue en arrière-plan", es: "Minimizar — la transferencia continúa en segundo plano" },
|
||||
"Submitting": { ru: "Отправка", de: "Übertrage", fr: "Envoi", es: "Enviando" },
|
||||
"Getting latest": { ru: "Получение", de: "Aktualisiere", fr: "Récupération", es: "Obteniendo" },
|
||||
"Included — click to exclude (won’t be synced)": { ru: "Включено — клик, чтобы исключить (не будет качаться)", de: "Enthalten — klicken zum Ausschließen (wird nicht synchronisiert)", fr: "Inclus — cliquer pour exclure (ne sera pas synchronisé)", es: "Incluido — clic para excluir (no se sincronizará)" },
|
||||
"Excluded — click to include (will be synced)": { ru: "Исключено — клик, чтобы включить (будет качаться)", de: "Ausgeschlossen — klicken zum Einschließen (wird synchronisiert)", fr: "Exclu — cliquer pour inclure (sera synchronisé)", es: "Excluido — clic para incluir (se sincronizará)" },
|
||||
"(no subfolders)": { ru: "(нет подпапок)", de: "(keine Unterordner)", fr: "(aucun sous-dossier)", es: "(sin subcarpetas)" },
|
||||
"Themes": { ru: "Темы", de: "Themes", fr: "Thèmes", es: "Temas" },
|
||||
"Themes…": { ru: "Темы…", de: "Themes…", fr: "Thèmes…", es: "Temas…" },
|
||||
"Switch the color theme or make your own custom theme.": { ru: "Сменить цветовую тему или создать свою.", de: "Farbthema wechseln oder ein eigenes erstellen.", fr: "Changer de thème ou créer le vôtre.", es: "Cambia el tema de color o crea el tuyo." },
|
||||
"Plugins…": { ru: "Плагины…", de: "Plugins…", fr: "Extensions…", es: "Plugins…" },
|
||||
"Install and manage plugins that add extra features on top of the core app.": { ru: "Установка и управление плагинами, которые добавляют функции поверх ядра.", de: "Plugins installieren und verwalten, die Funktionen über der Kern-App hinzufügen.", fr: "Installer et gérer des extensions qui ajoutent des fonctions au cœur de l’app.", es: "Instala y gestiona plugins que añaden funciones sobre el núcleo." },
|
||||
"Custom theme": { ru: "Своя тема", de: "Eigenes Theme", fr: "Thème personnalisé", es: "Tema personalizado" },
|
||||
"{n} themes": { ru: "{n} тем", de: "{n} Themes", fr: "{n} thèmes", es: "{n} temas" },
|
||||
"My theme": { ru: "Моя тема", de: "Mein Theme", fr: "Mon thème", es: "Mi tema" },
|
||||
"Theme “{n}” saved.": { ru: "Тема «{n}» сохранена.", de: "Theme „{n}“ gespeichert.", fr: "Thème « {n} » enregistré.", es: "Tema «{n}» guardado." },
|
||||
"Theme “{n}” imported.": { ru: "Тема «{n}» импортирована.", de: "Theme „{n}“ importiert.", fr: "Thème « {n} » importé.", es: "Tema «{n}» importado." },
|
||||
"Light": { ru: "Светлая", de: "Hell", fr: "Clair", es: "Claro" },
|
||||
"Dark": { ru: "Тёмная", de: "Dunkel", fr: "Sombre", es: "Oscuro" },
|
||||
"custom": { ru: "своя", de: "eigen", fr: "perso", es: "propio" },
|
||||
"Export": { ru: "Экспорт", de: "Exportieren", fr: "Exporter", es: "Exportar" },
|
||||
"Import…": { ru: "Импорт…", de: "Importieren…", fr: "Importer…", es: "Importar…" },
|
||||
"New custom theme": { ru: "Новая своя тема", de: "Neues eigenes Theme", fr: "Nouveau thème perso", es: "Nuevo tema propio" },
|
||||
"Save & apply": { ru: "Сохранить и применить", de: "Speichern & anwenden", fr: "Enregistrer et appliquer", es: "Guardar y aplicar" },
|
||||
"Name": { ru: "Название", de: "Name", fr: "Nom", es: "Nombre" },
|
||||
"NEW": { ru: "НОВОЕ", de: "NEU", fr: "NOUVEAU", es: "NUEVO" },
|
||||
"Submitted less than 3 hours ago": { ru: "Засабмичено меньше 3 часов назад", de: "Vor weniger als 3 Stunden übermittelt", fr: "Soumis il y a moins de 3 heures", es: "Enviado hace menos de 3 horas" },
|
||||
"Plugins": { ru: "Плагины", de: "Plugins", fr: "Extensions", es: "Plugins" },
|
||||
"General": { ru: "Общие", de: "Allgemein", fr: "Général", es: "General" },
|
||||
"Check for updates": { ru: "Проверить обновления", de: "Nach Updates suchen", fr: "Vérifier les mises à jour", es: "Buscar actualizaciones" },
|
||||
"Update available — click to install": { ru: "Есть обновление — нажми, чтобы установить", de: "Update verfügbar — zum Installieren klicken", fr: "Mise à jour dispo — cliquez pour installer", es: "Actualización disponible — clic para instalar" },
|
||||
"Updates": { ru: "Обновления", de: "Updates", fr: "Mises à jour", es: "Actualizaciones" },
|
||||
"Checking for updates…": { ru: "Проверяю обновления…", de: "Suche nach Updates…", fr: "Recherche de mises à jour…", es: "Buscando actualizaciones…" },
|
||||
"You're on the latest version": { ru: "У тебя последняя версия", de: "Du hast die neueste Version", fr: "Vous avez la dernière version", es: "Tienes la última versión" },
|
||||
"Update check failed": { ru: "Не удалось проверить обновления", de: "Update-Prüfung fehlgeschlagen", fr: "Échec de la vérification", es: "Fallo al buscar actualizaciones" },
|
||||
"Appearance": { ru: "Оформление", de: "Darstellung", fr: "Apparence", es: "Apariencia" },
|
||||
"Base theme": { ru: "Базовая тема", de: "Basis-Theme", fr: "Thème de base", es: "Tema base" },
|
||||
"{n} installed": { ru: "установлено: {n}", de: "{n} installiert", fr: "{n} installés", es: "{n} instalados" },
|
||||
"Plugins add features on top of the core app. Browse the catalog below and install what you need — nothing is installed automatically. Toggle plugins on/off; enabled ones load on launch. Plugins run in-app — only install ones you trust.": { ru: "Плагины добавляют функции поверх ядра. Смотри каталог ниже и ставь нужное — ничего не ставится само. Включай/выключай; включённые грузятся при запуске. Плагины работают внутри приложения — ставь только те, которым доверяешь.", de: "Plugins erweitern die Kern-App. Durchsuche den Katalog unten und installiere, was du brauchst — nichts wird automatisch installiert. Plugins ein/aus schalten; aktivierte laden beim Start. Sie laufen in der App — installiere nur vertrauenswürdige.", fr: "Les extensions enrichissent le cœur de l’app. Parcourez le catalogue ci-dessous et installez ce qu’il faut — rien n’est installé automatiquement. Activez/désactivez ; les activées se chargent au démarrage. Elles s’exécutent dans l’app — n’installez que celles de confiance.", es: "Los plugins añaden funciones al núcleo. Explora el catálogo abajo e instala lo que necesites — nada se instala solo. Actívalos/desactívalos; los activos se cargan al iniciar. Se ejecutan en la app — instala solo los de confianza." },
|
||||
"No plugins installed yet.\nInstall one from a folder, or drop it into the plugins folder below.": { ru: "Плагинов пока нет.\nУстанови из папки или положи в папку плагинов ниже.", de: "Noch keine Plugins.\nAus einem Ordner installieren oder in den Plugin-Ordner unten legen.", fr: "Aucune extension.\nInstallez depuis un dossier ou déposez-la dans le dossier ci-dessous.", es: "Aún no hay plugins.\nInstala desde una carpeta o suéltalo en la carpeta de abajo." },
|
||||
"Enable": { ru: "Включить", de: "Aktivieren", fr: "Activer", es: "Activar" },
|
||||
"Disable": { ru: "Выключить", de: "Deaktivieren", fr: "Désactiver", es: "Desactivar" },
|
||||
"Official plugins": { ru: "Официальные плагины", de: "Offizielle Plugins", fr: "Extensions officielles", es: "Plugins oficiales" },
|
||||
"Available to install": { ru: "Доступно для установки", de: "Verfügbar zur Installation", fr: "Disponibles à l’installation", es: "Disponibles para instalar" },
|
||||
"from the Exbyte plugin registry": { ru: "из реестра плагинов Exbyte", de: "aus der Exbyte-Plugin-Registry", fr: "depuis le registre de plugins Exbyte", es: "del registro de plugins de Exbyte" },
|
||||
"Loading the plugin catalog…": { ru: "Загружаю каталог плагинов…", de: "Plugin-Katalog wird geladen…", fr: "Chargement du catalogue…", es: "Cargando el catálogo…" },
|
||||
"Couldn't reach the plugin registry.": { ru: "Не удалось получить реестр плагинов.", de: "Plugin-Registry nicht erreichbar.", fr: "Registre de plugins injoignable.", es: "No se pudo acceder al registro." },
|
||||
"Retry": { ru: "Повторить", de: "Erneut", fr: "Réessayer", es: "Reintentar" },
|
||||
"Everything from the catalog is already installed.": { ru: "Всё из каталога уже установлено.", de: "Alles aus dem Katalog ist bereits installiert.", fr: "Tout le catalogue est déjà installé.", es: "Todo el catálogo ya está instalado." },
|
||||
"Install": { ru: "Установить", de: "Installieren", fr: "Installer", es: "Instalar" },
|
||||
"Open plugins folder": { ru: "Открыть папку плагинов", de: "Plugin-Ordner öffnen", fr: "Ouvrir le dossier des extensions", es: "Abrir carpeta de plugins" },
|
||||
"Reload": { ru: "Перезагрузить", de: "Neu laden", fr: "Recharger", es: "Recargar" },
|
||||
"Install from folder…": { ru: "Установить из папки…", de: "Aus Ordner installieren…", fr: "Installer depuis un dossier…", es: "Instalar desde carpeta…" },
|
||||
"Pick a plugin folder (contains plugin.json)": { ru: "Выбери папку плагина (с plugin.json)", de: "Plugin-Ordner wählen (mit plugin.json)", fr: "Choisir un dossier d’extension (avec plugin.json)", es: "Elige una carpeta de plugin (con plugin.json)" },
|
||||
"Installed plugin “{id}”.": { ru: "Плагин «{id}» установлен.", de: "Plugin „{id}“ installiert.", fr: "Extension « {id} » installée.", es: "Plugin «{id}» instalado." },
|
||||
"Removed plugin “{n}”.": { ru: "Плагин «{n}» удалён.", de: "Plugin „{n}“ entfernt.", fr: "Extension « {n} » supprimée.", es: "Plugin «{n}» eliminado." },
|
||||
"{n} plugin(s) loaded": { ru: "загружено плагинов: {n}", de: "{n} Plugin(s) geladen", fr: "{n} extension(s) chargée(s)", es: "{n} plugin(s) cargados" },
|
||||
"From plugin: {p}": { ru: "Из плагина: {p}", de: "Von Plugin: {p}", fr: "De l’extension : {p}", es: "Del plugin: {p}" },
|
||||
"Plugin view": { ru: "Панель плагина", de: "Plugin-Ansicht", fr: "Vue d’extension", es: "Vista de plugin" },
|
||||
"A plugin flagged this commit": { ru: "Плагин отметил этот коммит", de: "Ein Plugin hat diesen Commit markiert", fr: "Une extension a signalé ce commit", es: "Un plugin marcó este commit" },
|
||||
"A plugin blocked the commit.": { ru: "Плагин заблокировал коммит.", de: "Ein Plugin hat den Commit blockiert.", fr: "Une extension a bloqué le commit.", es: "Un plugin bloqueó el commit." },
|
||||
"Commit anyway": { ru: "Всё равно закоммитить", de: "Trotzdem committen", fr: "Valider quand même", es: "Confirmar de todos modos" },
|
||||
"Switch between the folder tree and the raw spec": { ru: "Переключить дерево папок / сырой спек", de: "Zwischen Ordnerbaum und Rohspezifikation wechseln", fr: "Basculer entre l’arborescence et la spec brute", es: "Alternar entre árbol de carpetas y spec en bruto" },
|
||||
"Folder tree (include / exclude)": { ru: "Дерево папок (включить / исключить)", de: "Ordnerbaum (ein-/ausschließen)", fr: "Arborescence (inclure / exclure)", es: "Árbol de carpetas (incluir / excluir)" },
|
||||
"Raw spec text": { ru: "Сырой текст спека", de: "Rohspezifikation", fr: "Spec brute", es: "Spec en bruto" },
|
||||
"Check a folder to include it in this workspace, uncheck to exclude. Excluded folders are not synced (not downloaded). This edits the depot → workspace View mapping.": { ru: "Отметь папку, чтобы включить её в воркспейс, сними галочку — исключить. Исключённые папки не синкаются (не качаются). Это правит маппинг View (депот → воркспейс).", de: "Ordner ankreuzen zum Einschließen, abwählen zum Ausschließen. Ausgeschlossene Ordner werden nicht synchronisiert. Bearbeitet das View-Mapping (Depot → Workspace).", fr: "Cochez un dossier pour l’inclure, décochez pour l’exclure. Les dossiers exclus ne sont pas synchronisés. Modifie le mapping View (dépôt → workspace).", es: "Marca una carpeta para incluirla, desmárcala para excluirla. Las carpetas excluidas no se sincronizan. Edita el mapeo View (depósito → workspace)." },
|
||||
"Workspace mapping changed": { ru: "Маппинг воркспейса изменён", de: "Workspace-Mapping geändert", fr: "Mapping du workspace modifié", es: "Mapeo del workspace cambiado" },
|
||||
"Get latest now to match the new view? Newly-included folders download; excluded folders are removed from disk on the next sync.": { ru: "Подтянуть сейчас под новый view? Только что включённые папки скачаются; исключённые удалятся с диска при следующем синке.", de: "Jetzt aktualisieren, um dem neuen View zu entsprechen? Neu eingeschlossene Ordner werden geladen; ausgeschlossene werden beim nächsten Sync von der Platte entfernt.", fr: "Récupérer maintenant selon le nouveau view ? Les dossiers nouvellement inclus sont téléchargés ; les exclus sont supprimés du disque au prochain sync.", es: "¿Obtener ahora según el nuevo view? Las carpetas recién incluidas se descargan; las excluidas se eliminan del disco en el próximo sync." },
|
||||
"Stop before the server commit. Files already sent are not applied; the changelist stays pending.": { ru: "Остановить до серверного коммита. Уже отправленные файлы не применяются; changelist останется в pending.", de: "Vor dem Server-Commit stoppen. Bereits gesendete Dateien werden nicht übernommen; die Changelist bleibt ausstehend.", fr: "Arrêter avant le commit serveur. Les fichiers déjà envoyés ne sont pas appliqués ; la changelist reste en attente.", es: "Detener antes del commit del servidor. Los archivos ya enviados no se aplican; la changelist queda pendiente." },
|
||||
"Stop the scan. Files already found stay staged.": { ru: "Остановить скан. Уже найденные файлы останутся помеченными.", de: "Scan stoppen. Bereits gefundene Dateien bleiben vorgemerkt.", fr: "Arrêter l’analyse. Les fichiers déjà trouvés restent préparés.", es: "Detener el escaneo. Los archivos ya encontrados quedan preparados." },
|
||||
"{a} of {b} selected": { ru: "{a} из {b} выбрано", de: "{a} von {b} ausgewählt", fr: "{a} sur {b} sélectionnés", es: "{a} de {b} seleccionados" },
|
||||
"Select all": { ru: "Выделить все", de: "Alle auswählen", fr: "Tout sélectionner", es: "Seleccionar todo" },
|
||||
"Deselect all": { ru: "Снять выделение", de: "Auswahl aufheben", fr: "Tout désélectionner", es: "Deseleccionar todo" },
|
||||
@ -190,7 +275,13 @@ const D: Record<string, Tr> = {
|
||||
"Local changes to {name} will be lost permanently — the file returns to the server version.": { ru: "Локальные изменения в {name} будут потеряны безвозвратно — файл вернётся к версии с сервера.", de: "Lokale Änderungen an {name} gehen endgültig verloren — die Datei kehrt zur Serverversion zurück.", fr: "Les modifications locales de {name} seront perdues définitivement — le fichier revient à la version du serveur.", es: "Los cambios locales en {name} se perderán para siempre — el archivo vuelve a la versión del servidor." },
|
||||
"Discarded: {name}": { ru: "Отменено: {name}", de: "Verworfen: {name}", fr: "Abandonné : {name}", es: "Descartado: {name}" },
|
||||
"Edit in VS Code": { ru: "Открыть в VS Code", de: "In VS Code öffnen", fr: "Ouvrir dans VS Code", es: "Abrir en VS Code" },
|
||||
"Open in VS Code": { ru: "Открыть в VS Code", de: "In VS Code öffnen", fr: "Ouvrir dans VS Code", es: "Abrir en VS Code" },
|
||||
"Opening in VS Code…": { ru: "Открываю в VS Code…", de: "Wird in VS Code geöffnet…", fr: "Ouverture dans VS Code…", es: "Abriendo en VS Code…" },
|
||||
"Open in {editor}": { ru: "Открыть в {editor}", de: "In {editor} öffnen", fr: "Ouvrir dans {editor}", es: "Abrir en {editor}" },
|
||||
"Opening in {editor}…": { ru: "Открываю в {editor}…", de: "Wird in {editor} geöffnet…", fr: "Ouverture dans {editor}…", es: "Abriendo en {editor}…" },
|
||||
"Code editor": { ru: "Редактор кода", de: "Code-Editor", fr: "Éditeur de code", es: "Editor de código" },
|
||||
"Open code files in this editor": { ru: "Открывать файлы кода в этом редакторе", de: "Code-Dateien in diesem Editor öffnen", fr: "Ouvrir les fichiers de code dans cet éditeur", es: "Abrir archivos de código en este editor" },
|
||||
"System default": { ru: "Системный по умолчанию", de: "Systemstandard", fr: "Application par défaut", es: "Predeterminado del sistema" },
|
||||
"Revert changelist #{n}?": { ru: "Откатить changelist #{n}?", de: "Changelist #{n} zurücksetzen?", fr: "Annuler le changelist #{n} ?", es: "¿Revertir changelist #{n}?" },
|
||||
"Perforce will create a NEW changelist that undoes #{n} and submit it to the server. History is kept (like “Revert” in GitHub Desktop).\n\n“{desc}”": { ru: "Perforce создаст НОВЫЙ changelist, отменяющий изменения из #{n}, и засабмитит его на сервер. История сохранится (как «Revert» в GitHub Desktop).\n\n«{desc}»", de: "Perforce erstellt eine NEUE Changelist, die #{n} rückgängig macht, und sendet sie an den Server. Der Verlauf bleibt erhalten (wie „Revert“ in GitHub Desktop).\n\n„{desc}“", fr: "Perforce créera un NOUVEAU changelist qui annule #{n} et l’enverra au serveur. L’historique est conservé (comme « Revert » dans GitHub Desktop).\n\n« {desc} »", es: "Perforce creará un NUEVO changelist que deshace #{n} y lo enviará al servidor. El historial se conserva (como «Revert» en GitHub Desktop).\n\n«{desc}»" },
|
||||
"Revert #{n}": { ru: "Откатить #{n}", de: "#{n} zurücksetzen", fr: "Annuler #{n}", es: "Revertir #{n}" },
|
||||
@ -308,6 +399,289 @@ 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." },
|
||||
// session expired → re-auth prompt
|
||||
"Session expired": { ru: "Сессия истекла", de: "Sitzung abgelaufen", fr: "Session expirée", es: "Sesión expirada" },
|
||||
"Perforce ended the session. Sign in again to carry on — your workspace and open changes are untouched.": { ru: "Perforce завершил сессию. Войдите снова, чтобы продолжить — workspace и открытые правки не тронуты.", de: "Perforce hat die Sitzung beendet. Melde dich erneut an — dein Workspace und offene Änderungen bleiben unberührt.", fr: "Perforce a mis fin à la session. Reconnectez-vous pour continuer — votre workspace et vos modifications ouvertes sont intacts.", es: "Perforce terminó la sesión. Inicia sesión de nuevo para continuar: tu workspace y los cambios abiertos están intactos." },
|
||||
"Reconnect": { ru: "Переподключиться", de: "Neu verbinden", fr: "Se reconnecter", es: "Reconectar" },
|
||||
"Sign out": { ru: "Выйти", de: "Abmelden", fr: "Se déconnecter", es: "Cerrar sesión" },
|
||||
"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." },
|
||||
"Compile the game C++ — Unreal projects build via UnrealBuildTool (game module only), plain C++ via MSBuild.": { ru: "Скомпилировать C++ игры — Unreal-проекты собираются через UnrealBuildTool (только модуль игры), обычный C++ — через MSBuild.", de: "Game-C++ kompilieren — Unreal-Projekte über UnrealBuildTool (nur Spielmodul), reines C++ über MSBuild.", fr: "Compiler le C++ du jeu — les projets Unreal via UnrealBuildTool (module de jeu seulement), le C++ simple via MSBuild.", es: "Compilar el C++ del juego — los proyectos Unreal vía UnrealBuildTool (solo el módulo del juego), C++ simple vía MSBuild." },
|
||||
"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." },
|
||||
// ---- new folder / project ----
|
||||
"New": { ru: "Создать", de: "Neu", fr: "Nouveau", es: "Nuevo" },
|
||||
"workspace root": { ru: "корень воркспейса", de: "Arbeitsbereich-Root", fr: "racine de l'espace de travail", es: "raíz del espacio de trabajo" },
|
||||
"New folder / project…": { ru: "Новая папка / проект…", de: "Neuer Ordner / Projekt…", fr: "Nouveau dossier / projet…", es: "Nueva carpeta / proyecto…" },
|
||||
"Create a new folder in the working area (with a README placeholder) — e.g. a separate project.": { ru: "Создать новую папку в рабочей области (с плейсхолдером README) — например, отдельный проект.", de: "Einen neuen Ordner im Arbeitsbereich erstellen (mit README-Platzhalter) — z. B. ein separates Projekt.", fr: "Créer un nouveau dossier dans l'espace de travail (avec un README) — p. ex. un projet distinct.", es: "Crear una nueva carpeta en el área de trabajo (con un README) — p. ej. un proyecto aparte." },
|
||||
"New folder in {where}": { ru: "Новая папка в {where}", de: "Neuer Ordner in {where}", fr: "Nouveau dossier dans {where}", es: "Nueva carpeta en {where}" },
|
||||
"Folder name — nest with / (e.g. NewGame or NewGame/Source)": { ru: "Имя папки — вложенность через / (напр. NewGame или NewGame/Source)", de: "Ordnername — verschachteln mit / (z. B. NewGame oder NewGame/Source)", fr: "Nom du dossier — imbriquer avec / (p. ex. NewGame ou NewGame/Source)", es: "Nombre de la carpeta — anida con / (p. ej. NewGame o NewGame/Source)" },
|
||||
"Created “{name}” with a README placeholder — commit it to keep the folder on the server.": { ru: "Создано «{name}» с плейсхолдером README — закоммить, чтобы папка сохранилась на сервере.", de: "„{name}“ mit README-Platzhalter erstellt — committe es, damit der Ordner auf dem Server bleibt.", fr: "« {name} » créé avec un README — committe-le pour conserver le dossier sur le serveur.", es: "«{name}» creado con un README — haz commit para conservar la carpeta en el servidor." },
|
||||
// ---- rename / delete / properties folder ----
|
||||
"Rename…": { ru: "Переименовать…", de: "Umbenennen…", fr: "Renommer…", es: "Renombrar…" },
|
||||
"Rename folder": { ru: "Переименовать папку", de: "Ordner umbenennen", fr: "Renommer le dossier", es: "Renombrar carpeta" },
|
||||
"New name for “{name}”": { ru: "Новое имя для «{name}»", de: "Neuer Name für „{name}“", fr: "Nouveau nom pour « {name} »", es: "Nuevo nombre para «{name}»" },
|
||||
"Renamed to “{name}” — {n} file(s) moved. Commit to apply.": { ru: "Переименовано в «{name}» — перемещено {n} файл(ов). Закоммить, чтобы применить.", de: "In „{name}“ umbenannt — {n} Datei(en) verschoben. Zum Anwenden committen.", fr: "Renommé en « {name} » — {n} fichier(s) déplacés. Committe pour appliquer.", es: "Renombrado a «{name}» — {n} archivo(s) movidos. Haz commit para aplicar." },
|
||||
"Delete folder…": { ru: "Удалить папку…", de: "Ordner löschen…", fr: "Supprimer le dossier…", es: "Eliminar carpeta…" },
|
||||
"Delete folder “{name}”?": { ru: "Удалить папку «{name}»?", de: "Ordner „{name}“ löschen?", fr: "Supprimer le dossier « {name} » ?", es: "¿Eliminar la carpeta «{name}»?" },
|
||||
"Every file under this folder is marked for delete. Nothing leaves the server until you Submit; freshly-added files are removed right away.": { ru: "Все файлы под этой папкой помечаются на удаление. С сервера ничего не уходит до Submit; свежедобавленные файлы убираются сразу.", de: "Jede Datei unter diesem Ordner wird zum Löschen markiert. Nichts verlässt den Server bis zum Submit; frisch hinzugefügte Dateien werden sofort entfernt.", fr: "Chaque fichier de ce dossier est marqué pour suppression. Rien ne quitte le serveur avant le Submit ; les fichiers récemment ajoutés sont retirés immédiatement.", es: "Todos los archivos de esta carpeta se marcan para eliminar. Nada sale del servidor hasta el Submit; los archivos recién añadidos se quitan al instante." },
|
||||
"Marked {n} file(s) for delete — Submit to remove the folder.": { ru: "Помечено {n} файл(ов) на удаление — Submit уберёт папку.", de: "{n} Datei(en) zum Löschen markiert — Submit entfernt den Ordner.", fr: "{n} fichier(s) marqués pour suppression — Submit retire le dossier.", es: "{n} archivo(s) marcados para eliminar — Submit quita la carpeta." },
|
||||
"Folder removed.": { ru: "Папка удалена.", de: "Ordner entfernt.", fr: "Dossier supprimé.", es: "Carpeta eliminada." },
|
||||
"Delete": { ru: "Удалить", de: "Löschen", fr: "Supprimer", es: "Eliminar" },
|
||||
"Properties": { ru: "Свойства", de: "Eigenschaften", fr: "Propriétés", es: "Propiedades" },
|
||||
"Folder properties": { ru: "Свойства папки", de: "Ordner-Eigenschaften", fr: "Propriétés du dossier", es: "Propiedades de la carpeta" },
|
||||
"Files": { ru: "Файлов", de: "Dateien", fr: "Fichiers", es: "Archivos" },
|
||||
"Total size": { ru: "Общий размер", de: "Gesamtgröße", fr: "Taille totale", es: "Tamaño total" },
|
||||
"Location": { ru: "Расположение", de: "Speicherort", fr: "Emplacement", es: "Ubicación" },
|
||||
"depot": { ru: "депо", de: "Depot", fr: "depot", es: "depot" },
|
||||
"local only": { ru: "только локально", de: "nur lokal", fr: "local uniquement", es: "solo local" },
|
||||
"Recently": { ru: "Недавние", de: "Zuletzt", fr: "Récents", es: "Recientes" },
|
||||
"local": { ru: "локально", de: "lokal", fr: "local", es: "local" },
|
||||
};
|
||||
|
||||
export function t(en: string, vars?: Record<string, string | number>): string {
|
||||
|
||||
222
src/p4.ts
@ -1,5 +1,31 @@
|
||||
// Typed wrappers around the Rust p4 commands.
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { invoke as rawInvoke } from "@tauri-apps/api/core";
|
||||
|
||||
/** True when p4 rejected us for auth reasons — an expired or dropped ticket —
|
||||
* as opposed to the server being unreachable. Perforce expires tickets on its
|
||||
* own schedule (often 12h), so a workday can outlive a login. */
|
||||
export function isAuthError(e: unknown): boolean {
|
||||
return /P4PASSWD|invalid or unset|session has expired|session was logged out|please login again/i
|
||||
.test(String(e));
|
||||
}
|
||||
|
||||
// Set by the app: called when any command comes back with an auth failure, so
|
||||
// the UI can ask for the password again instead of dead-ending on every action.
|
||||
let authLost: (() => void) | null = null;
|
||||
export function onAuthLost(fn: (() => void) | null) { authLost = fn; }
|
||||
|
||||
// Commands that legitimately report bad credentials — the login screen shows
|
||||
// those inline, and firing the re-auth prompt from them would loop.
|
||||
const AUTH_CMDS = new Set(["p4_login", "p4_restore", "p4_clients"]);
|
||||
|
||||
/** Every p4 command funnels through here so a lost ticket is caught in one
|
||||
* place rather than at each of the ~110 call sites. */
|
||||
function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||
return rawInvoke<T>(cmd, args).catch((e) => {
|
||||
if (!AUTH_CMDS.has(cmd) && isAuthError(e)) authLost?.();
|
||||
throw e;
|
||||
});
|
||||
}
|
||||
|
||||
export interface P4Info {
|
||||
serverAddress?: string;
|
||||
@ -64,6 +90,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 +117,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 }),
|
||||
@ -78,14 +128,39 @@ export const p4 = {
|
||||
del: (files: string[]) => invoke<OpenedFile[]>("p4_delete", { files }),
|
||||
reconcile: (path: string) => invoke<OpenedFile[]>("p4_reconcile", { path }),
|
||||
scan: (scope = "") => invoke<OpenedFile[]>("p4_scan", { scope }),
|
||||
// streaming disk scan: emits `p4-scan` progress events, returns the file count.
|
||||
// The caller re-reads opened() for the actual list.
|
||||
scanStream: (scope = "") => invoke<number>("p4_scan_stream", { scope }),
|
||||
scanCancel: () => invoke<void>("p4_scan_cancel"),
|
||||
// ask an in-progress submit/sync transfer to stop (safe pre-commit for submit)
|
||||
transferCancel: () => invoke<void>("p4_transfer_cancel"),
|
||||
// --- plugins ---
|
||||
pluginList: () => invoke<PluginInfo[]>("plugin_list"),
|
||||
pluginReadEntry: (id: string) => invoke<string>("plugin_read_entry", { id }),
|
||||
pluginSetEnabled: (id: string, enabled: boolean) => invoke<void>("plugin_set_enabled", { id, enabled }),
|
||||
pluginWriteFile: (id: string, name: string, content: string) => invoke<void>("plugin_write_file", { id, name, content }),
|
||||
registryHttpGet: (url: string) => invoke<string>("registry_http_get", { url }),
|
||||
// generic HTTP pipe for relay-backed plugins (any http(s) URL, optional bearer)
|
||||
relayHttp: (url: string, method: string, body?: string, token?: string) =>
|
||||
invoke<string>("relay_http", { url, method, body, token }),
|
||||
// shelve everything open in the default changelist → { change, files }
|
||||
shelveOpen: (description: string) =>
|
||||
invoke<{ change: string; files: number }>("p4_shelve_open", { description }),
|
||||
// caller's max protection level on the server (super/admin/write/…)
|
||||
protectsMax: () => invoke<string>("p4_protects_max"),
|
||||
pluginInstall: (src: string) => invoke<string>("plugin_install", { src }),
|
||||
pluginRemove: (id: string) => invoke<void>("plugin_remove", { id }),
|
||||
pluginsDirPath: () => invoke<string>("plugins_dir_path"),
|
||||
openPluginsDir: () => invoke<void>("open_plugins_dir"),
|
||||
describe: (change: string) => invoke<Describe>("p4_describe", { change }),
|
||||
undoChange: (change: string) => invoke<string>("p4_undo_change", { change }),
|
||||
openInExplorer: (target: string) => invoke<void>("open_in_explorer", { target }),
|
||||
findUproject: (scope: string) => invoke<string>("find_uproject", { scope }),
|
||||
findSln: (scope: string) => invoke<string>("find_sln", { scope }),
|
||||
buildSln: (path: string, config: string) => invoke<string>("build_sln", { path, config }),
|
||||
buildSln: (path: string, config: string, uproject = "") => invoke<string>("build_sln", { path, config, uproject }),
|
||||
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,17 +176,71 @@ 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 }),
|
||||
makeFolder: (scope: string, name: string) => invoke<string>("make_folder", { scope, name }),
|
||||
deleteFolder: (path: string) => invoke<OpenedFile[]>("delete_folder", { path }),
|
||||
renameFolder: (path: string, newName: string) => invoke<OpenedFile[]>("rename_folder", { path, newName }),
|
||||
folderInfo: (path: string) => invoke<{ path: string; fileCount: number; size: number }>("folder_info", { path }),
|
||||
// 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 }
|
||||
|
||||
export interface Dir { dir?: 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; local?: boolean; [k: string]: unknown }
|
||||
export interface PluginInfo { id: string; name: string; version: string; description: string; author: string; permissions: string[]; manifest: Record<string, unknown>; enabled: boolean; dir: string }
|
||||
// depot path -> short leaf name (last segment)
|
||||
export function leaf(path?: string): string {
|
||||
if (!path) return "";
|
||||
@ -119,6 +248,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 }[] {
|
||||
@ -151,6 +311,17 @@ export function saveScope(client: string, path: string) {
|
||||
export function loadScope(client: string): string {
|
||||
try { return client ? (localStorage.getItem("exd-scope-" + client) || "") : ""; } catch { return ""; }
|
||||
}
|
||||
// last 3 chosen working folders (Recently) — most-recent first, per workspace
|
||||
export function recentScopes(client: string): string[] {
|
||||
try { const a = JSON.parse(localStorage.getItem("exd-recent-" + client) || "[]"); return Array.isArray(a) ? a.slice(0, 3) : []; } catch { return []; }
|
||||
}
|
||||
export function pushRecentScope(client: string, path: string) {
|
||||
try {
|
||||
const cur = recentScopes(client).filter((p) => p !== path);
|
||||
const next = [path, ...cur].slice(0, 3);
|
||||
localStorage.setItem("exd-recent-" + client, JSON.stringify(next));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// format a unix-epoch string as a short local datetime
|
||||
export function fmtTime(t?: string): string {
|
||||
@ -161,6 +332,35 @@ export function fmtTime(t?: string): string {
|
||||
return d.toLocaleString(undefined, { day: "2-digit", month: "2-digit", year: "numeric", hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
/** Condense `p4 sync` stdout into one short line.
|
||||
*
|
||||
* A sync prints one line per file — `//depot/F.uasset#3 - updating d:\ws\F.uasset` —
|
||||
* so a real Get Latest returns thousands of full paths. Dumping that into a toast
|
||||
* buries the screen; the per-file detail already streamed into the transfer dialog.
|
||||
* Short outputs ("File(s) up-to-date.") pass through unchanged. */
|
||||
export function syncSummary(out: string): string {
|
||||
const text = (out || "").trim();
|
||||
if (!text) return "up to date";
|
||||
const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
|
||||
// p4 says its piece in one line when nothing moved ("File(s) up-to-date.") and
|
||||
// so do server errors — keep those verbatim; flash() caps the length.
|
||||
if (lines.length === 1) return lines[0];
|
||||
let added = 0, updated = 0, deleted = 0;
|
||||
for (const l of lines) {
|
||||
if (/ - (added as|refreshing) /.test(l)) added++;
|
||||
else if (/ - (updating|replacing) /.test(l)) updated++;
|
||||
else if (/ - deleted as /.test(l)) deleted++;
|
||||
}
|
||||
const moved = added + updated + deleted;
|
||||
// unrecognised shape (server notices, errors) — report the line count, not the text
|
||||
if (!moved) return `${lines.length.toLocaleString()} lines`;
|
||||
const parts: string[] = [];
|
||||
if (added) parts.push(`+${added.toLocaleString()}`);
|
||||
if (updated) parts.push(`~${updated.toLocaleString()}`);
|
||||
if (deleted) parts.push(`−${deleted.toLocaleString()}`);
|
||||
return `${moved.toLocaleString()} files (${parts.join(" ")})`;
|
||||
}
|
||||
|
||||
// action -> single-char status + css class
|
||||
export function statusOf(action?: string): { ch: string; cls: string } {
|
||||
const a = (action || "").toLowerCase();
|
||||
@ -178,11 +378,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);
|
||||
}
|
||||
|
||||
227
src/plugins.ts
Normal file
@ -0,0 +1,227 @@
|
||||
// Plugin platform — Host SDK + loader + contribution registry.
|
||||
//
|
||||
// Plugins are plain JS/ESM modules (author in TS → compile to JS). Each ships a
|
||||
// `plugin.json` manifest + a JS entry that exports `activate(host)`. We read the
|
||||
// entry source from disk (Rust) and import it as an ES module from a blob: URL —
|
||||
// no eval, works under CSP `script-src blob:`. The plugin only touches the app
|
||||
// through the `host` object; capabilities are gated by declared permissions.
|
||||
|
||||
import { p4, PluginInfo } from "./p4";
|
||||
import { t } from "./i18n";
|
||||
import { resolveTheme, loadActiveId } from "./themes";
|
||||
|
||||
// ---- contribution shapes ----------------------------------------------------
|
||||
// `icon` is a NAME from the host icon set (e.g. "ue", "hammer"); the host renders
|
||||
// the real SVG. Unknown / omitted names fall back to a neutral glyph.
|
||||
export interface PluginMenuItem { pluginId: string; label: string; icon?: string; run: () => void | Promise<void> }
|
||||
export interface PluginFileItem { pluginId: string; label: string; icon?: string; run: (file: { depotFile?: string; [k: string]: unknown }) => void | Promise<void> }
|
||||
export interface PluginFolderItem { pluginId: string; label: string; icon?: string; visible?: () => boolean; run: () => void | Promise<void> }
|
||||
export interface PluginView { pluginId: string; id: string; title: string; render: (container: HTMLElement) => void | (() => void) }
|
||||
export interface PluginDock { pluginId: string; id: string; title: string; icon?: string; render: (container: HTMLElement) => void | (() => void) }
|
||||
export interface PluginSubmitHook { pluginId: string; run: (files: { depotFile?: string }[]) => SubmitVerdict | Promise<SubmitVerdict> }
|
||||
export type SubmitVerdict = { ok: boolean; message?: string };
|
||||
|
||||
interface Registry { menu: PluginMenuItem[]; fileItems: PluginFileItem[]; folderItems: PluginFolderItem[]; views: PluginView[]; docks: PluginDock[]; submitHooks: PluginSubmitHook[] }
|
||||
const registry: Registry = { menu: [], fileItems: [], folderItems: [], views: [], docks: [], submitHooks: [] };
|
||||
let version = 0;
|
||||
const listeners = new Set<() => void>();
|
||||
function bump() { version++; listeners.forEach((l) => l()); }
|
||||
|
||||
export function getRegistry(): Registry { return registry; }
|
||||
export function registryVersion(): number { return version; }
|
||||
export function subscribeRegistry(cb: () => void): () => void { listeners.add(cb); return () => { listeners.delete(cb); }; }
|
||||
function clearRegistry() { registry.menu = []; registry.fileItems = []; registry.folderItems = []; registry.views = []; registry.docks = []; registry.submitHooks = []; }
|
||||
|
||||
// Unreal Engine bridge — the core keeps the build machinery + overlay + log
|
||||
// reader; the plugin only drives them. Gated by the "ue" permission.
|
||||
export interface UeBridge {
|
||||
hasProject: () => boolean;
|
||||
uproject: () => string;
|
||||
build: () => void;
|
||||
launch: () => void;
|
||||
readLog: (tailBytes?: number) => Promise<string>;
|
||||
}
|
||||
|
||||
// Team bridge — presence + coordinator tools. The plugin drives these; the core
|
||||
// just exposes the p4 primitives + a generic HTTP pipe to the relay. Gated by
|
||||
// the "team" permission. Nothing here touches the Perforce server config: shelve
|
||||
// is an ordinary client-side operation and `relay` talks only to the URL the
|
||||
// plugin was configured with.
|
||||
export interface TeamBridge {
|
||||
whoami: () => { user?: string; client?: string; root?: string } | null;
|
||||
maxAccess: () => Promise<string>; // "super" | "admin" | "write" | …
|
||||
openCount: () => Promise<number>; // files open in this workspace
|
||||
shelveOpen: (desc: string) => Promise<{ change: string; files: number }>;
|
||||
submitShelved: (change: string) => Promise<string>;
|
||||
// dumb HTTP pipe to the configured relay (backend does the request; no CORS)
|
||||
relay: (url: string, method: string, body?: string, token?: string) => Promise<string>;
|
||||
}
|
||||
|
||||
// ---- host context (provided by the app) -------------------------------------
|
||||
export interface HostContext {
|
||||
flash: (text: string, err?: boolean) => void;
|
||||
notify: (title: string, body: string) => void;
|
||||
info: () => { userName?: string; clientName?: string; clientRoot?: string } | null;
|
||||
selectedFile: () => { depotFile?: string } | null;
|
||||
refresh: () => void;
|
||||
ue?: UeBridge;
|
||||
team?: TeamBridge;
|
||||
}
|
||||
|
||||
// ---- the Host SDK a plugin receives -----------------------------------------
|
||||
export interface HostApi {
|
||||
version: string;
|
||||
plugin: { id: string; name: string; dir: string };
|
||||
perms: string[];
|
||||
has: (perm: string) => boolean;
|
||||
p4: Record<string, (...a: unknown[]) => Promise<unknown>>;
|
||||
ui: {
|
||||
addMenuItem: (label: string, run: () => void | Promise<void>, opts?: { icon?: string }) => void;
|
||||
addFileContextItem: (label: string, run: (file: { depotFile?: string }) => void | Promise<void>, opts?: { icon?: string }) => void;
|
||||
addFolderContextItem: (label: string, run: () => void | Promise<void>, opts?: { icon?: string; visible?: () => boolean }) => void;
|
||||
addView: (view: { id: string; title: string; render: (el: HTMLElement, host: HostApi) => void | (() => void) }) => void;
|
||||
addDockPanel: (dock: { id: string; title: string; icon?: string; render: (el: HTMLElement, host: HostApi) => void | (() => void) }) => void;
|
||||
addSubmitHook: (run: (files: { depotFile?: string }[]) => SubmitVerdict | Promise<SubmitVerdict>) => void;
|
||||
};
|
||||
flash: (text: string, err?: boolean) => void;
|
||||
notify: (title: string, body: string) => void;
|
||||
clipboard: { write: (text: string) => Promise<void> };
|
||||
storage: { get: (k: string) => string | null; set: (k: string, v: string) => void; remove: (k: string) => void };
|
||||
info: () => ReturnType<HostContext["info"]>;
|
||||
selectedFile: () => { depotFile?: string } | null;
|
||||
refresh: () => void;
|
||||
ue?: UeBridge; // present only with the "ue" permission
|
||||
team?: TeamBridge; // present only with the "team" permission
|
||||
theme: () => Record<string, string>;
|
||||
t: typeof t;
|
||||
log: (...a: unknown[]) => void;
|
||||
}
|
||||
|
||||
const HOST_VERSION = "1.0";
|
||||
|
||||
// p4 methods a plugin may call, split by permission. Only whitelisted, existing methods.
|
||||
const P4_READ = ["opened", "dirs", "describe", "changeSizes", "clientSpec"] as const;
|
||||
const P4_WRITE = ["add", "edit", "del", "sync", "reconcile"] as const;
|
||||
|
||||
function makeHost(info: PluginInfo, ctx: HostContext): HostApi {
|
||||
const perms = Array.isArray(info.permissions) ? info.permissions : [];
|
||||
const has = (p: string) => perms.includes(p);
|
||||
const p4host: Record<string, (...a: unknown[]) => Promise<unknown>> = {};
|
||||
const anyP4 = p4 as unknown as Record<string, (...a: unknown[]) => Promise<unknown>>;
|
||||
if (has("p4:read")) for (const m of P4_READ) if (typeof anyP4[m] === "function") p4host[m] = (...a) => anyP4[m]!(...a);
|
||||
if (has("p4:write")) for (const m of P4_WRITE) if (typeof anyP4[m] === "function") p4host[m] = (...a) => anyP4[m]!(...a);
|
||||
|
||||
const ns = `exd-plugin:${info.id}:`;
|
||||
const host: HostApi = {
|
||||
version: HOST_VERSION,
|
||||
plugin: { id: info.id, name: info.name, dir: info.dir },
|
||||
perms,
|
||||
has,
|
||||
p4: p4host,
|
||||
ui: {
|
||||
addMenuItem: (label, run, opts) => { registry.menu.push({ pluginId: info.id, label, icon: opts?.icon, run }); bump(); },
|
||||
addFileContextItem: (label, run, opts) => { registry.fileItems.push({ pluginId: info.id, label, icon: opts?.icon, run }); bump(); },
|
||||
addFolderContextItem: (label, run, opts) => { registry.folderItems.push({ pluginId: info.id, label, icon: opts?.icon, visible: opts?.visible, run }); bump(); },
|
||||
addView: (view) => { registry.views.push({ pluginId: info.id, id: view.id, title: view.title, render: (el) => view.render(el, host) }); bump(); },
|
||||
addDockPanel: (dock) => { registry.docks.push({ pluginId: info.id, id: dock.id, title: dock.title, icon: dock.icon, render: (el) => dock.render(el, host) }); bump(); },
|
||||
addSubmitHook: (run) => { registry.submitHooks.push({ pluginId: info.id, run }); bump(); },
|
||||
},
|
||||
flash: ctx.flash,
|
||||
notify: has("notify") ? ctx.notify : () => {},
|
||||
clipboard: { write: (text) => navigator.clipboard.writeText(text) },
|
||||
storage: {
|
||||
get: (k) => { try { return localStorage.getItem(ns + k); } catch { return null; } },
|
||||
set: (k, v) => { try { localStorage.setItem(ns + k, v); } catch {} },
|
||||
remove: (k) => { try { localStorage.removeItem(ns + k); } catch {} },
|
||||
},
|
||||
info: ctx.info,
|
||||
selectedFile: ctx.selectedFile,
|
||||
refresh: ctx.refresh,
|
||||
theme: () => resolveTheme(loadActiveId()).vars,
|
||||
t,
|
||||
log: (...a) => console.log(`[plugin:${info.id}]`, ...a),
|
||||
};
|
||||
if (has("ue") && ctx.ue) host.ue = ctx.ue; // Unreal bridge, permission-gated
|
||||
if (has("team") && ctx.team) host.team = ctx.team; // Team bridge, permission-gated
|
||||
return host;
|
||||
}
|
||||
|
||||
// import a plugin's source as an ES module from a blob: URL, then activate it
|
||||
async function loadOne(info: PluginInfo, ctx: HostContext): Promise<void> {
|
||||
const code = await p4.pluginReadEntry(info.id);
|
||||
const blob = new Blob([code], { type: "text/javascript" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
try {
|
||||
const mod = await import(/* @vite-ignore */ url);
|
||||
const activate = mod.activate || mod.default;
|
||||
if (typeof activate !== "function") throw new Error("plugin has no activate(host) export");
|
||||
await activate(makeHost(info, ctx));
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
/** (Re)load all enabled plugins. Clears the registry first so this is idempotent. */
|
||||
export async function loadPlugins(ctx: HostContext): Promise<{ loaded: number; errors: string[] }> {
|
||||
clearRegistry();
|
||||
const errors: string[] = [];
|
||||
let loaded = 0;
|
||||
let list: PluginInfo[] = [];
|
||||
try { list = await p4.pluginList(); } catch (e) { return { loaded: 0, errors: [String(e)] }; }
|
||||
for (const info of list) {
|
||||
if (!info.enabled) continue;
|
||||
try { await loadOne(info, ctx); loaded++; }
|
||||
catch (e) { errors.push(`${info.name || info.id}: ${String(e)}`); }
|
||||
}
|
||||
bump();
|
||||
return { loaded, errors };
|
||||
}
|
||||
|
||||
/** Run all plugin submit hooks; returns the first blocking verdict, or ok. */
|
||||
export async function runSubmitHooks(files: { depotFile?: string }[]): Promise<SubmitVerdict> {
|
||||
for (const h of registry.submitHooks) {
|
||||
try { const v = await h.run(files); if (v && !v.ok) return v; }
|
||||
catch { /* a broken hook must not block submit */ }
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
// ---- official plugin registry (hosted on Gitea) -----------------------------
|
||||
// The client fetches registry.json over HTTPS (frontend fetch — no Rust HTTP dep),
|
||||
// then hands each file to the backend to write into the plugins folder.
|
||||
export const REGISTRY_URL = "https://gitea.exbytestudios.com/ExbytePublicServices/exbyte-depot-plugins/raw/branch/main/registry.json";
|
||||
|
||||
export interface RegistryEntry {
|
||||
id: string; name: string; version: string; author?: string; description?: string;
|
||||
permissions?: string[]; minAppVersion?: string; official?: boolean; autoInstall?: boolean;
|
||||
files: string[]; baseUrl: string;
|
||||
}
|
||||
|
||||
// All registry HTTP goes through the Rust backend (registryHttpGet): the webview
|
||||
// `fetch()` is blocked by CORS because Gitea's raw endpoint sends no ACAO header.
|
||||
export async function fetchRegistry(): Promise<RegistryEntry[]> {
|
||||
const txt = await p4.registryHttpGet(REGISTRY_URL);
|
||||
const j = JSON.parse(txt);
|
||||
return Array.isArray(j.plugins) ? (j.plugins as RegistryEntry[]).filter((p) => p && p.id && Array.isArray(p.files)) : [];
|
||||
}
|
||||
|
||||
/** Download a registry plugin's files (backend HTTPS) and write them into the plugins folder. */
|
||||
export async function installFromRegistry(entry: RegistryEntry): Promise<void> {
|
||||
for (const f of entry.files) {
|
||||
const text = await p4.registryHttpGet(entry.baseUrl + f);
|
||||
await p4.pluginWriteFile(entry.id, f, text);
|
||||
}
|
||||
}
|
||||
|
||||
/** Install any official plugins marked autoInstall that aren't installed yet. Silent on network failure. */
|
||||
export async function autoInstallOfficial(installedIds: Set<string>): Promise<string[]> {
|
||||
let reg: RegistryEntry[] = [];
|
||||
try { reg = await fetchRegistry(); } catch { return []; }
|
||||
const done: string[] = [];
|
||||
for (const e of reg) {
|
||||
if (e.autoInstall && !installedIds.has(e.id)) {
|
||||
try { await installFromRegistry(e); done.push(e.id); } catch { /* offline / transient — try next launch */ }
|
||||
}
|
||||
}
|
||||
return done;
|
||||
}
|
||||
133
src/themes.ts
Normal file
@ -0,0 +1,133 @@
|
||||
// Theme system — built-in + user-made custom themes.
|
||||
//
|
||||
// A theme = a base (dark/light) plus an overlay of CSS-variable values applied as
|
||||
// inline styles on <body>, which win over the :root / body.light stylesheet vars.
|
||||
// Everything is CSS-variable driven, so a theme just changes token values.
|
||||
|
||||
export type ThemeBase = "dark" | "light";
|
||||
export interface Theme {
|
||||
id: string;
|
||||
name: string;
|
||||
base: ThemeBase;
|
||||
builtin?: boolean;
|
||||
vars: Record<string, string>; // e.g. { "--accent": "#7c6ef6" }
|
||||
}
|
||||
|
||||
// The tokens a user can edit. Grouped for the editor UI. Every one is a color.
|
||||
export const THEME_TOKENS: { var: string; label: string; group: string }[] = [
|
||||
{ var: "--accent", label: "Accent", group: "Brand" },
|
||||
{ var: "--accent-2", label: "Accent light", group: "Brand" },
|
||||
{ var: "--accent-deep", label: "Accent deep", group: "Brand" },
|
||||
{ var: "--bg", label: "Background", group: "Surfaces" },
|
||||
{ var: "--panel", label: "Panel", group: "Surfaces" },
|
||||
{ var: "--panel-2", label: "Panel 2", group: "Surfaces" },
|
||||
{ var: "--panel-3", label: "Panel 3", group: "Surfaces" },
|
||||
{ var: "--elevated", label: "Elevated", group: "Surfaces" },
|
||||
{ var: "--border", label: "Border", group: "Surfaces" },
|
||||
{ var: "--txt", label: "Text", group: "Text" },
|
||||
{ var: "--muted", label: "Muted text", group: "Text" },
|
||||
{ var: "--faint", label: "Faint text", group: "Text" },
|
||||
{ var: "--add", label: "Add / success", group: "Status" },
|
||||
{ var: "--edit", label: "Edit / warning", group: "Status" },
|
||||
{ var: "--del", label: "Delete / danger", group: "Status" },
|
||||
];
|
||||
export const TOKEN_VARS = THEME_TOKENS.map((t) => t.var);
|
||||
|
||||
// Built-in presets. "dark"/"light" carry no overlay (pure stylesheet base); the
|
||||
// rest recolor a few tokens on top of a base to give a distinct look.
|
||||
export const BUILTIN_THEMES: Theme[] = [
|
||||
{ id: "dark", name: "Midnight (default)", base: "dark", builtin: true, vars: {} },
|
||||
{ id: "light", name: "Daylight", base: "light", builtin: true, vars: {} },
|
||||
{ id: "ember", name: "Ember", base: "dark", builtin: true, vars: { "--accent": "#f0883e", "--accent-2": "#f7a75f", "--accent-deep": "#c96a22", "--bg": "#0f0b07", "--panel": "#1a130c", "--panel-2": "#20180f", "--panel-3": "#281d12" } },
|
||||
{ id: "forest", name: "Forest", base: "dark", builtin: true, vars: { "--accent": "#35c88a", "--accent-2": "#5fe0a5", "--accent-deep": "#22a06e", "--bg": "#07100b", "--panel": "#0e1a13", "--panel-2": "#12211a", "--panel-3": "#182a20" } },
|
||||
{ id: "ocean", name: "Ocean", base: "dark", builtin: true, vars: { "--accent": "#3aa0ff", "--accent-2": "#6cbaff", "--accent-deep": "#1f7ce0", "--bg": "#060b12", "--panel": "#0c141f", "--panel-2": "#101a28", "--panel-3": "#152234" } },
|
||||
{ id: "rose", name: "Rosé (light)", base: "light", builtin: true, vars: { "--accent": "#e0426a", "--accent-2": "#f06489", "--accent-deep": "#c02b52", "--bg": "#faf3f5" } },
|
||||
];
|
||||
|
||||
const CUSTOM_KEY = "exd-themes-custom";
|
||||
const ACTIVE_KEY = "exd-theme-id";
|
||||
const LEGACY_KEY = "exd-theme"; // old "light"/"dark" flag
|
||||
|
||||
export function loadCustomThemes(): Theme[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(CUSTOM_KEY);
|
||||
if (!raw) return [];
|
||||
const arr = JSON.parse(raw);
|
||||
return Array.isArray(arr) ? (arr as Theme[]).filter((t) => t && t.id && t.vars) : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
export function saveCustomThemes(list: Theme[]) {
|
||||
try { localStorage.setItem(CUSTOM_KEY, JSON.stringify(list)); } catch {}
|
||||
}
|
||||
export function allThemes(): Theme[] {
|
||||
return [...BUILTIN_THEMES, ...loadCustomThemes()];
|
||||
}
|
||||
export function loadActiveId(): string {
|
||||
try {
|
||||
const id = localStorage.getItem(ACTIVE_KEY);
|
||||
if (id) return id;
|
||||
// migrate from the old dark/light flag
|
||||
return localStorage.getItem(LEGACY_KEY) === "light" ? "light" : "dark";
|
||||
} catch { return "dark"; }
|
||||
}
|
||||
export function saveActiveId(id: string) {
|
||||
try {
|
||||
localStorage.setItem(ACTIVE_KEY, id);
|
||||
const th = allThemes().find((t) => t.id === id);
|
||||
localStorage.setItem(LEGACY_KEY, th?.base === "light" ? "light" : "dark"); // keep legacy in sync
|
||||
} catch {}
|
||||
}
|
||||
export function resolveTheme(id: string): Theme {
|
||||
return allThemes().find((t) => t.id === id) || BUILTIN_THEMES[0]!;
|
||||
}
|
||||
|
||||
// Apply a theme to the document: set the base class, then overlay its var values
|
||||
// (clearing any previous overlay first).
|
||||
export function applyTheme(theme: Theme) {
|
||||
const body = document.body;
|
||||
for (const v of TOKEN_VARS) body.style.removeProperty(v);
|
||||
body.classList.toggle("light", theme.base === "light");
|
||||
for (const [k, val] of Object.entries(theme.vars)) {
|
||||
if (val) body.style.setProperty(k, val);
|
||||
}
|
||||
}
|
||||
|
||||
// Read the effective value of a token right now (for seeding the editor). Falls
|
||||
// back to the theme's overlay or the computed stylesheet value.
|
||||
export function tokenValue(theme: Theme, varName: string): string {
|
||||
if (theme.vars[varName]) return theme.vars[varName]!;
|
||||
const el = document.createElement("div");
|
||||
el.style.color = `var(${varName})`;
|
||||
// temporarily set base so computed value reflects the right stylesheet
|
||||
const hadLight = document.body.classList.contains("light");
|
||||
document.body.classList.toggle("light", theme.base === "light");
|
||||
document.body.appendChild(el);
|
||||
const rgb = getComputedStyle(el).color;
|
||||
el.remove();
|
||||
document.body.classList.toggle("light", hadLight);
|
||||
return rgbToHex(rgb) || "#7c6ef6";
|
||||
}
|
||||
|
||||
function rgbToHex(rgb: string): string | null {
|
||||
const m = rgb.match(/rgba?\(([^)]+)\)/);
|
||||
if (!m || !m[1]) return null;
|
||||
const parts = m[1].split(",").map((x) => parseInt(x.trim(), 10));
|
||||
const [r, g, b] = parts;
|
||||
if (r == null || g == null || b == null) return null;
|
||||
const h = (n: number) => n.toString(16).padStart(2, "0");
|
||||
return `#${h(r)}${h(g)}${h(b)}`;
|
||||
}
|
||||
|
||||
export function newCustomId(): string {
|
||||
// no Date.now/Math.random dependency worries in the app runtime; keep it simple
|
||||
return "custom-" + Math.random().toString(36).slice(2, 9);
|
||||
}
|
||||
|
||||
export function exportTheme(theme: Theme): string {
|
||||
return JSON.stringify({ name: theme.name, base: theme.base, vars: theme.vars }, null, 2);
|
||||
}
|
||||
export function importTheme(json: string): Theme {
|
||||
const o = JSON.parse(json);
|
||||
if (!o || typeof o !== "object" || !o.vars) throw new Error("Invalid theme file");
|
||||
return { id: newCustomId(), name: String(o.name || "Imported theme"), base: o.base === "light" ? "light" : "dark", vars: o.vars };
|
||||
}
|
||||
18
waitlist-server/.env.example
Normal file
@ -0,0 +1,18 @@
|
||||
# Copy to .env and fill in. NEVER commit .env.
|
||||
|
||||
# Admin login
|
||||
ADMIN_USER=admin
|
||||
# Generate with: npm run set-password
|
||||
ADMIN_PASS_HASH=
|
||||
|
||||
# Session signing key (>=24 chars). Generate with: npm run gen-secret
|
||||
SESSION_SECRET=
|
||||
|
||||
# Server
|
||||
PORT=8787
|
||||
NODE_ENV=development
|
||||
# Set to 1 (or the hop count) when running behind nginx/Caddy so client IPs are correct
|
||||
TRUST_PROXY=0
|
||||
|
||||
# Path to the landing page folder (default: ../landing)
|
||||
# LANDING_DIR=../landing
|
||||
4
waitlist-server/.gitignore
vendored
Normal file
@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
.env
|
||||
data/
|
||||
*.log
|
||||
51
waitlist-server/README.md
Normal file
@ -0,0 +1,51 @@
|
||||
# Exbyte Depot — Waitlist server + admin
|
||||
|
||||
Collects waitlist emails from the landing page (deduplicated) and gives you a
|
||||
password-protected admin panel to browse them and export to CSV.
|
||||
|
||||
## Stack
|
||||
- **express** — HTTP + static landing
|
||||
- **better-sqlite3** — storage; `UNIQUE COLLATE NOCASE` on email → no duplicates, ever
|
||||
- **express-rate-limit** — brute-force protection on the login endpoint
|
||||
- **node:crypto** — scrypt password hashing + HMAC-signed session cookies (no extra deps)
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cd waitlist-server
|
||||
npm install
|
||||
|
||||
cp .env.example .env
|
||||
npm run gen-secret # → paste into SESSION_SECRET in .env
|
||||
npm run set-password # type a password → paste the ADMIN_PASS_HASH line into .env
|
||||
# set ADMIN_USER in .env (default: admin)
|
||||
|
||||
npm start
|
||||
```
|
||||
|
||||
- Landing: http://localhost:8787/
|
||||
- Admin: http://localhost:8787/admin
|
||||
|
||||
## How it works
|
||||
|
||||
- The landing form POSTs `{ email }` to **`POST /api/waitlist`**.
|
||||
- Invalid emails are rejected; a repeat email is silently deduped (no dup row).
|
||||
- A hidden honeypot field (`website`) traps bots.
|
||||
- **`/admin`** requires login. Session is a signed, HttpOnly, SameSite=Strict cookie
|
||||
(Secure in production), valid 8h. Wrong logins are rate-limited (8 / 15 min / IP).
|
||||
- **Export CSV**: `GET /admin/export.csv` streams all signups (UTF-8 + BOM for Excel).
|
||||
|
||||
## Security notes
|
||||
- No plaintext password anywhere — only a scrypt hash in `.env` (git-ignored).
|
||||
- Refuses to start without `ADMIN_USER`, `ADMIN_PASS_HASH`, and a strong `SESSION_SECRET`.
|
||||
- Security headers set (CSP, X-Frame-Options: DENY, nosniff, no-referrer).
|
||||
- Behind a reverse proxy, set `TRUST_PROXY=1` so rate-limiting and stored IPs are correct,
|
||||
and terminate TLS at the proxy (cookies become `Secure` when `NODE_ENV=production`).
|
||||
|
||||
## Deploy (Linux VPS, sketch)
|
||||
1. `NODE_ENV=production`, real `.env`, `npm ci --omit=dev`.
|
||||
2. Run under systemd (or pm2); put nginx/Caddy in front for TLS + `TRUST_PROXY=1`.
|
||||
3. Point your domain at it; the landing is served from `../landing` (or set `LANDING_DIR`).
|
||||
|
||||
## Data
|
||||
SQLite file at `data/waitlist.db` (git-ignored). Back it up to keep your list.
|
||||
129
waitlist-server/lib/auth.js
Normal file
@ -0,0 +1,129 @@
|
||||
// Authentication for the admin panel — no external crypto deps, all from node:crypto.
|
||||
//
|
||||
// • Passwords are stored as scrypt hashes ("scrypt$salt$hash", both base64url) in
|
||||
// the ADMIN_PASS_HASH env var. Never a plaintext password anywhere.
|
||||
// • Sessions are stateless, signed cookies: base64url(payload).base64url(hmac).
|
||||
// Tamper-proof (HMAC-SHA256 with SESSION_SECRET) and self-expiring. No server
|
||||
// session store to leak or lose on restart.
|
||||
// • Every compare is constant-time (timingSafeEqual) to avoid timing oracles.
|
||||
|
||||
import crypto from "node:crypto";
|
||||
|
||||
const SCRYPT = { N: 16384, r: 8, p: 1, keylen: 32 };
|
||||
|
||||
/** Hash a plaintext password → "scrypt$<salt>$<hash>" (for storing in .env). */
|
||||
export function hashPassword(password) {
|
||||
const salt = crypto.randomBytes(16);
|
||||
const hash = crypto.scryptSync(password, salt, SCRYPT.keylen, SCRYPT);
|
||||
return `scrypt$${salt.toString("base64url")}$${hash.toString("base64url")}`;
|
||||
}
|
||||
|
||||
/** Verify a plaintext password against a stored "scrypt$salt$hash" string. */
|
||||
export function verifyPassword(password, stored) {
|
||||
if (typeof stored !== "string") return false;
|
||||
const parts = stored.split("$");
|
||||
if (parts.length !== 3 || parts[0] !== "scrypt") return false;
|
||||
let salt, expected;
|
||||
try {
|
||||
salt = Buffer.from(parts[1], "base64url");
|
||||
expected = Buffer.from(parts[2], "base64url");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (expected.length !== SCRYPT.keylen) return false;
|
||||
const got = crypto.scryptSync(password, salt, SCRYPT.keylen, SCRYPT);
|
||||
return crypto.timingSafeEqual(got, expected);
|
||||
}
|
||||
|
||||
/** Constant-time string compare (for the username). */
|
||||
export function safeEqual(a, b) {
|
||||
const ba = Buffer.from(String(a));
|
||||
const bb = Buffer.from(String(b));
|
||||
if (ba.length !== bb.length) return false;
|
||||
return crypto.timingSafeEqual(ba, bb);
|
||||
}
|
||||
|
||||
// ---- signed session cookies -------------------------------------------------
|
||||
|
||||
const COOKIE_NAME = "exd_admin";
|
||||
|
||||
function sign(data, secret) {
|
||||
return crypto.createHmac("sha256", secret).update(data).digest("base64url");
|
||||
}
|
||||
|
||||
/** Build a signed session token that expires after `ttlMs`. */
|
||||
export function issueToken(username, secret, ttlMs) {
|
||||
const payload = JSON.stringify({ u: username, exp: Date.now() + ttlMs });
|
||||
const body = Buffer.from(payload).toString("base64url");
|
||||
return `${body}.${sign(body, secret)}`;
|
||||
}
|
||||
|
||||
/** Verify a session token → { u } if valid & unexpired, else null. */
|
||||
export function verifyToken(token, secret) {
|
||||
if (typeof token !== "string" || !token.includes(".")) return null;
|
||||
const [body, mac] = token.split(".");
|
||||
if (!body || !mac) return null;
|
||||
const expected = sign(body, secret);
|
||||
const a = Buffer.from(mac);
|
||||
const b = Buffer.from(expected);
|
||||
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return null;
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!payload || typeof payload.exp !== "number" || Date.now() > payload.exp) return null;
|
||||
return { u: payload.u };
|
||||
}
|
||||
|
||||
/** Parse the admin session cookie out of a raw Cookie header. */
|
||||
export function readSessionCookie(req) {
|
||||
const raw = req.headers.cookie || "";
|
||||
for (const part of raw.split(";")) {
|
||||
const i = part.indexOf("=");
|
||||
if (i === -1) continue;
|
||||
if (part.slice(0, i).trim() === COOKIE_NAME) {
|
||||
return decodeURIComponent(part.slice(i + 1).trim());
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function setSessionCookie(res, token, { secure, ttlMs }) {
|
||||
const attrs = [
|
||||
`${COOKIE_NAME}=${encodeURIComponent(token)}`,
|
||||
"HttpOnly",
|
||||
"SameSite=Strict",
|
||||
"Path=/",
|
||||
`Max-Age=${Math.floor(ttlMs / 1000)}`,
|
||||
];
|
||||
if (secure) attrs.push("Secure");
|
||||
res.append("Set-Cookie", attrs.join("; "));
|
||||
}
|
||||
|
||||
export function clearSessionCookie(res, { secure }) {
|
||||
const attrs = [`${COOKIE_NAME}=`, "HttpOnly", "SameSite=Strict", "Path=/", "Max-Age=0"];
|
||||
if (secure) attrs.push("Secure");
|
||||
res.append("Set-Cookie", attrs.join("; "));
|
||||
}
|
||||
|
||||
/** Express middleware: allow through only requests with a valid session. */
|
||||
export function requireAuth(secret) {
|
||||
return (req, res, next) => {
|
||||
const tok = readSessionCookie(req);
|
||||
const sess = tok && verifyToken(tok, secret);
|
||||
if (!sess) {
|
||||
// req.path is stripped of the mount prefix, so match on originalUrl
|
||||
const url = req.originalUrl || req.url || "";
|
||||
if (url.startsWith("/admin/api") || url.endsWith(".csv")) {
|
||||
return res.status(401).json({ error: "unauthorized" });
|
||||
}
|
||||
return res.redirect("/admin/login");
|
||||
}
|
||||
req.admin = sess;
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
export { COOKIE_NAME };
|
||||
56
waitlist-server/lib/db.js
Normal file
@ -0,0 +1,56 @@
|
||||
// SQLite storage for waitlist signups. One row per unique email — dedup is
|
||||
// enforced by the DB itself (UNIQUE, case-insensitive), so a repeat signup is a
|
||||
// no-op rather than a duplicate row.
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const DATA_DIR = path.join(__dirname, "..", "data");
|
||||
const DB_PATH = process.env.DB_PATH || path.join(DATA_DIR, "waitlist.db");
|
||||
|
||||
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
||||
|
||||
const db = new Database(DB_PATH);
|
||||
db.pragma("journal_mode = WAL");
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS signups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
source TEXT,
|
||||
ip TEXT,
|
||||
user_agent TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ','now'))
|
||||
);
|
||||
`);
|
||||
|
||||
const insertStmt = db.prepare(
|
||||
`INSERT INTO signups (email, source, ip, user_agent)
|
||||
VALUES (@email, @source, @ip, @user_agent)
|
||||
ON CONFLICT(email) DO NOTHING`
|
||||
);
|
||||
const countStmt = db.prepare(`SELECT COUNT(*) AS n FROM signups`);
|
||||
const listStmt = db.prepare(
|
||||
`SELECT id, email, source, ip, created_at FROM signups ORDER BY created_at DESC, id DESC`
|
||||
);
|
||||
|
||||
/**
|
||||
* Add an email. Returns { added: true } on a new signup, { added: false } if the
|
||||
* email was already on the list (deduped — no duplicate stored).
|
||||
*/
|
||||
export function addEmail({ email, source = null, ip = null, userAgent = null }) {
|
||||
const info = insertStmt.run({ email, source, ip, user_agent: userAgent });
|
||||
return { added: info.changes > 0 };
|
||||
}
|
||||
|
||||
export function countEmails() {
|
||||
return countStmt.get().n;
|
||||
}
|
||||
|
||||
export function listEmails() {
|
||||
return listStmt.all();
|
||||
}
|
||||
|
||||
export default db;
|
||||
1271
waitlist-server/package-lock.json
generated
Normal file
22
waitlist-server/package.json
Normal file
@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "exbyte-depot-waitlist",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Waitlist collector + secure admin for the Exbyte Depot landing page",
|
||||
"type": "module",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "node --watch server.js",
|
||||
"set-password": "node scripts/set-password.js",
|
||||
"gen-secret": "node -e \"console.log(require('crypto').randomBytes(48).toString('base64url'))\""
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"dependencies": {
|
||||
"better-sqlite3": "^11.8.1",
|
||||
"express": "^4.21.2",
|
||||
"express-rate-limit": "^7.5.0"
|
||||
}
|
||||
}
|
||||
37
waitlist-server/scripts/set-password.js
Normal file
@ -0,0 +1,37 @@
|
||||
// Generate an ADMIN_PASS_HASH for .env from a password.
|
||||
//
|
||||
// node scripts/set-password.js "my super secret"
|
||||
// node scripts/set-password.js # prompts (hidden input)
|
||||
//
|
||||
// Paste the printed line into .env — the plaintext password is never stored.
|
||||
|
||||
import readline from "node:readline";
|
||||
import { hashPassword } from "../lib/auth.js";
|
||||
|
||||
function ask(question) {
|
||||
return new Promise((resolve) => {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
// hide typed characters
|
||||
const orig = rl._writeToOutput.bind(rl);
|
||||
rl._writeToOutput = (str) => {
|
||||
if (str.includes(question)) orig(str);
|
||||
else orig("*");
|
||||
};
|
||||
rl.question(question, (answer) => {
|
||||
rl.close();
|
||||
process.stdout.write("\n");
|
||||
resolve(answer);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const argPw = process.argv.slice(2).join(" ").trim();
|
||||
const pw = argPw || (await ask("New admin password: "));
|
||||
|
||||
if (!pw || pw.length < 8) {
|
||||
console.error("Password must be at least 8 characters.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log("\nAdd this line to your .env:\n");
|
||||
console.log(`ADMIN_PASS_HASH=${hashPassword(pw)}\n`);
|
||||
209
waitlist-server/server.js
Normal file
@ -0,0 +1,209 @@
|
||||
// Exbyte Depot — waitlist collector + secure admin panel.
|
||||
//
|
||||
// Public: serves the landing page and accepts POST /api/waitlist (dedup).
|
||||
// Admin: password-gated (scrypt), rate-limited, signed-cookie session.
|
||||
// view signups + export CSV. No plaintext secrets in the codebase.
|
||||
|
||||
import express from "express";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { addEmail, countEmails, listEmails } from "./lib/db.js";
|
||||
import {
|
||||
verifyPassword,
|
||||
safeEqual,
|
||||
issueToken,
|
||||
requireAuth,
|
||||
setSessionCookie,
|
||||
clearSessionCookie,
|
||||
} from "./lib/auth.js";
|
||||
|
||||
// --- load .env (built into Node, no dotenv dependency) ----------------------
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
try {
|
||||
process.loadEnvFile(path.join(__dirname, ".env"));
|
||||
} catch {
|
||||
/* no .env file — rely on real environment variables */
|
||||
}
|
||||
|
||||
const {
|
||||
PORT = 8787,
|
||||
NODE_ENV = "development",
|
||||
ADMIN_USER,
|
||||
ADMIN_PASS_HASH,
|
||||
SESSION_SECRET,
|
||||
TRUST_PROXY = "0",
|
||||
LANDING_DIR = path.join(__dirname, "..", "landing"),
|
||||
} = process.env;
|
||||
|
||||
const IS_PROD = NODE_ENV === "production";
|
||||
const SESSION_TTL_MS = 8 * 60 * 60 * 1000; // 8 hours
|
||||
|
||||
// --- refuse to boot without the security-critical config --------------------
|
||||
const missing = [];
|
||||
if (!ADMIN_USER) missing.push("ADMIN_USER");
|
||||
if (!ADMIN_PASS_HASH) missing.push("ADMIN_PASS_HASH");
|
||||
if (!SESSION_SECRET || SESSION_SECRET.length < 24) missing.push("SESSION_SECRET (>=24 chars)");
|
||||
if (missing.length) {
|
||||
console.error(
|
||||
`\n[waitlist] Refusing to start — missing/weak config: ${missing.join(", ")}\n` +
|
||||
` 1) cp .env.example .env\n` +
|
||||
` 2) npm run gen-secret # paste into SESSION_SECRET\n` +
|
||||
` 3) npm run set-password # paste into ADMIN_PASS_HASH\n`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const app = express();
|
||||
app.disable("x-powered-by");
|
||||
if (TRUST_PROXY !== "0") app.set("trust proxy", Number(TRUST_PROXY) || 1);
|
||||
app.use(express.json({ limit: "16kb" }));
|
||||
app.use(express.urlencoded({ extended: false, limit: "16kb" }));
|
||||
|
||||
// --- security headers (self-contained; no external origins) -----------------
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader("X-Content-Type-Options", "nosniff");
|
||||
res.setHeader("X-Frame-Options", "DENY");
|
||||
res.setHeader("Referrer-Policy", "no-referrer");
|
||||
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
|
||||
res.setHeader("Permissions-Policy", "geolocation=(), microphone=(), camera=()");
|
||||
res.setHeader(
|
||||
"Content-Security-Policy",
|
||||
[
|
||||
"default-src 'self'",
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
"script-src 'self' 'unsafe-inline'",
|
||||
"img-src 'self' data:",
|
||||
"base-uri 'self'",
|
||||
"form-action 'self'",
|
||||
"frame-ancestors 'none'",
|
||||
].join("; ")
|
||||
);
|
||||
next();
|
||||
});
|
||||
|
||||
// --- rate limiters ----------------------------------------------------------
|
||||
const loginLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 8, // per IP per window
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: "Too many attempts. Try again later." },
|
||||
});
|
||||
const waitlistLimiter = rateLimit({
|
||||
windowMs: 10 * 60 * 1000,
|
||||
max: 20,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: "Too many requests. Slow down." },
|
||||
});
|
||||
|
||||
// --- helpers ----------------------------------------------------------------
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
function normalizeEmail(v) {
|
||||
return String(v || "").trim().toLowerCase();
|
||||
}
|
||||
function clientIp(req) {
|
||||
return (req.ip || "").replace(/^::ffff:/, "") || null;
|
||||
}
|
||||
function sendView(res, file, replacements = {}) {
|
||||
let html = fs.readFileSync(path.join(__dirname, "views", file), "utf8");
|
||||
for (const [k, v] of Object.entries(replacements)) {
|
||||
html = html.replaceAll(`{{${k}}}`, v);
|
||||
}
|
||||
res.type("html").send(html);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// PUBLIC — waitlist signup
|
||||
// ============================================================================
|
||||
app.post("/api/waitlist", waitlistLimiter, (req, res) => {
|
||||
// honeypot: bots fill hidden fields; humans leave them blank
|
||||
if (req.body && typeof req.body.website === "string" && req.body.website.trim() !== "") {
|
||||
return res.json({ ok: true, deduped: true }); // silently accept, store nothing
|
||||
}
|
||||
const email = normalizeEmail(req.body?.email);
|
||||
if (!email || email.length > 254 || !EMAIL_RE.test(email)) {
|
||||
return res.status(400).json({ ok: false, error: "Enter a valid email." });
|
||||
}
|
||||
try {
|
||||
const { added } = addEmail({
|
||||
email,
|
||||
source: (req.body?.source || "landing").toString().slice(0, 64),
|
||||
ip: clientIp(req),
|
||||
userAgent: (req.headers["user-agent"] || "").slice(0, 300),
|
||||
});
|
||||
return res.json({ ok: true, added, deduped: !added });
|
||||
} catch (e) {
|
||||
console.error("[waitlist] insert failed:", e.message);
|
||||
return res.status(500).json({ ok: false, error: "Server error." });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// ADMIN — auth
|
||||
// ============================================================================
|
||||
app.get("/admin/login", (req, res) => sendView(res, "login.html"));
|
||||
|
||||
app.post("/admin/login", loginLimiter, (req, res) => {
|
||||
const user = String(req.body?.username || "");
|
||||
const pass = String(req.body?.password || "");
|
||||
const ok = safeEqual(user, ADMIN_USER) && verifyPassword(pass, ADMIN_PASS_HASH);
|
||||
if (!ok) {
|
||||
return res.status(401).redirect("/admin/login?error=1");
|
||||
}
|
||||
const token = issueToken(ADMIN_USER, SESSION_SECRET, SESSION_TTL_MS);
|
||||
setSessionCookie(res, token, { secure: IS_PROD, ttlMs: SESSION_TTL_MS });
|
||||
res.redirect("/admin");
|
||||
});
|
||||
|
||||
app.post("/admin/logout", (req, res) => {
|
||||
clearSessionCookie(res, { secure: IS_PROD });
|
||||
res.redirect("/admin/login");
|
||||
});
|
||||
|
||||
// everything below requires a valid session
|
||||
app.use("/admin", requireAuth(SESSION_SECRET));
|
||||
|
||||
app.get("/admin", (req, res) => sendView(res, "admin.html", { USER: req.admin.u }));
|
||||
|
||||
app.get("/admin/api/emails", (req, res) => {
|
||||
res.json({ count: countEmails(), emails: listEmails() });
|
||||
});
|
||||
|
||||
// CSV export
|
||||
app.get("/admin/export.csv", (req, res) => {
|
||||
const rows = listEmails();
|
||||
const esc = (v) => {
|
||||
const s = v == null ? "" : String(v);
|
||||
return /[",\n\r]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s;
|
||||
};
|
||||
const header = ["email", "source", "ip", "created_at"];
|
||||
const lines = [header.join(",")];
|
||||
for (const r of rows) {
|
||||
lines.push([r.email, r.source, r.ip, r.created_at].map(esc).join(","));
|
||||
}
|
||||
const csv = "" + lines.join("\r\n"); // BOM so Excel reads UTF-8
|
||||
const stamp = new Date().toISOString().slice(0, 10);
|
||||
res.setHeader("Content-Type", "text/csv; charset=utf-8");
|
||||
res.setHeader("Content-Disposition", `attachment; filename="waitlist-${stamp}.csv"`);
|
||||
res.send(csv);
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// LANDING (static) — served last so /api and /admin win
|
||||
// ============================================================================
|
||||
app.use(
|
||||
express.static(LANDING_DIR, {
|
||||
extensions: ["html"],
|
||||
setHeaders: (res) => res.setHeader("Cache-Control", "no-cache"),
|
||||
})
|
||||
);
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`[waitlist] listening on http://localhost:${PORT}`);
|
||||
console.log(`[waitlist] landing: ${LANDING_DIR}`);
|
||||
console.log(`[waitlist] admin: http://localhost:${PORT}/admin (${IS_PROD ? "prod" : "dev"})`);
|
||||
});
|
||||
155
waitlist-server/views/admin.html
Normal file
@ -0,0 +1,155 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<title>Waitlist · Exbyte Depot Admin</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0a0a0f; --bg-2:#0d0d14; --panel:#12121b; --panel-2:#16161f; --elevated:#1a1a24;
|
||||
--border:#26263340; --txt:#ececf4; --muted:#a2a2b4; --faint:#6d6d80;
|
||||
--acc:#7c6ef6; --acc-2:#9d93ff; --acc-deep:#5b4ee0; --ok:#3ecf8e; --del:#f2637e;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;min-height:100vh;background:var(--bg);color:var(--txt);
|
||||
font:14.5px/1.5 -apple-system,Segoe UI,Roboto,Inter,system-ui,sans-serif}
|
||||
.wrap{max-width:1000px;margin:0 auto;padding:24px 22px 60px}
|
||||
header{display:flex;align-items:center;gap:12px;padding:6px 0 22px}
|
||||
.logo{width:34px;height:34px;border-radius:10px;flex:0 0 auto;
|
||||
background:linear-gradient(145deg,var(--acc-2),var(--acc-deep));
|
||||
box-shadow:0 0 18px rgba(124,110,246,.45);display:grid;place-items:center}
|
||||
.logo svg{width:18px;height:18px}
|
||||
.htitle b{font-size:15px;font-weight:650}
|
||||
.htitle span{display:block;font-size:11.5px;color:var(--faint)}
|
||||
.spacer{flex:1}
|
||||
.who{font-size:12.5px;color:var(--muted);margin-right:4px}
|
||||
.who b{color:var(--txt);font-weight:600}
|
||||
.btn{display:inline-flex;align-items:center;gap:7px;font-size:13px;font-weight:600;cursor:pointer;
|
||||
background:var(--panel-2);border:1px solid var(--border);color:var(--txt);
|
||||
border-radius:10px;padding:8px 13px;transition:.15s;text-decoration:none}
|
||||
.btn:hover{border-color:var(--acc);color:#fff}
|
||||
.btn svg{width:15px;height:15px}
|
||||
.btn.accent{background:linear-gradient(145deg,var(--acc-2),var(--acc-deep));border:0;color:#fff}
|
||||
.btn.accent:hover{filter:brightness(1.08)}
|
||||
.btn.ghost{background:transparent}
|
||||
.stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:14px;margin-bottom:20px}
|
||||
.stat{background:var(--panel);border:1px solid var(--border);border-radius:14px;padding:16px 18px}
|
||||
.stat .n{font-size:28px;font-weight:700;letter-spacing:-.5px;font-variant-numeric:tabular-nums}
|
||||
.stat .l{font-size:11.5px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px;margin-top:2px}
|
||||
.stat.accent .n{color:var(--acc-2)}
|
||||
.toolbar{display:flex;gap:10px;align-items:center;margin-bottom:14px;flex-wrap:wrap}
|
||||
.search{flex:1;min-width:200px;display:flex;align-items:center;gap:8px;
|
||||
background:var(--elevated);border:1px solid var(--border);border-radius:10px;padding:0 12px}
|
||||
.search svg{width:15px;height:15px;color:var(--faint);flex:0 0 auto}
|
||||
.search input{flex:1;background:none;border:0;outline:none;color:var(--txt);font-size:14px;padding:10px 0}
|
||||
.panel{background:var(--panel);border:1px solid var(--border);border-radius:14px;overflow:hidden}
|
||||
table{width:100%;border-collapse:collapse}
|
||||
th,td{text-align:left;padding:11px 16px;font-size:13.5px}
|
||||
th{background:var(--panel-2);color:var(--muted);font-weight:600;font-size:11.5px;
|
||||
text-transform:uppercase;letter-spacing:.4px;position:sticky;top:0}
|
||||
tbody tr{border-top:1px solid var(--border)}
|
||||
tbody tr:hover{background:#ffffff06}
|
||||
td.email{font-weight:550}
|
||||
td.mono{font-variant-numeric:tabular-nums;color:var(--muted);white-space:nowrap}
|
||||
.tag{display:inline-block;font-size:11px;color:var(--muted);background:var(--panel-2);
|
||||
border:1px solid var(--border);border-radius:6px;padding:1px 8px}
|
||||
.idx{color:var(--faint);font-variant-numeric:tabular-nums;width:42px}
|
||||
.empty{padding:44px;text-align:center;color:var(--faint);font-size:13.5px}
|
||||
.foot{margin-top:16px;font-size:11.5px;color:var(--faint);text-align:center}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<header>
|
||||
<span class="logo"><svg viewBox="0 0 24 24" fill="none"><path d="M4 7l8-4 8 4v10l-8 4-8-4V7Z" stroke="#fff" stroke-width="1.6" stroke-linejoin="round"/><path d="M4 7l8 4 8-4M12 11v10" stroke="#fff" stroke-width="1.6" stroke-linejoin="round"/></svg></span>
|
||||
<div class="htitle"><b>Waitlist</b><span>Exbyte Depot · admin</span></div>
|
||||
<div class="spacer"></div>
|
||||
<span class="who">Signed in as <b>{{USER}}</b></span>
|
||||
<form method="POST" action="/admin/logout" style="display:inline">
|
||||
<button class="btn ghost" type="submit">Log out</button>
|
||||
</form>
|
||||
</header>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat accent"><div class="n" id="statTotal">—</div><div class="l">Total signups</div></div>
|
||||
<div class="stat"><div class="n" id="statShown">—</div><div class="l">Showing</div></div>
|
||||
<div class="stat"><div class="n" id="statLatest">—</div><div class="l">Latest signup</div></div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<div class="search">
|
||||
<svg viewBox="0 0 24 24" fill="none"><circle cx="11" cy="11" r="7" stroke="currentColor" stroke-width="1.7"/><path d="m20 20-3-3" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg>
|
||||
<input id="search" type="search" placeholder="Filter emails…" autocomplete="off" />
|
||||
</div>
|
||||
<button class="btn" id="refresh" type="button">
|
||||
<svg viewBox="0 0 24 24" fill="none"><path d="M20 11a8 8 0 1 0-.5 3M20 5v6h-6" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
Refresh
|
||||
</button>
|
||||
<a class="btn accent" href="/admin/export.csv">
|
||||
<svg viewBox="0 0 24 24" fill="none"><path d="M12 3v12m0 0 4-4m-4 4-4-4M5 21h14" stroke="#fff" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>
|
||||
Export CSV
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th class="idx">#</th><th>Email</th><th>Source</th><th>Date (UTC)</th></tr>
|
||||
</thead>
|
||||
<tbody id="rows"></tbody>
|
||||
</table>
|
||||
<div class="empty" id="empty" style="display:none">No signups yet.</div>
|
||||
</div>
|
||||
<div class="foot" id="foot"></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let all = [];
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
function fmtDate(s){ return (s || "").replace("T"," ").replace("Z",""); }
|
||||
|
||||
function render(){
|
||||
const q = $("search").value.trim().toLowerCase();
|
||||
const list = q ? all.filter(r => r.email.toLowerCase().includes(q)) : all;
|
||||
const tbody = $("rows");
|
||||
tbody.textContent = "";
|
||||
$("empty").style.display = list.length ? "none" : "block";
|
||||
list.forEach((r, i) => {
|
||||
const tr = document.createElement("tr");
|
||||
const idx = document.createElement("td"); idx.className = "idx"; idx.textContent = i + 1;
|
||||
const em = document.createElement("td"); em.className = "email"; em.textContent = r.email;
|
||||
const src = document.createElement("td");
|
||||
const tag = document.createElement("span"); tag.className = "tag"; tag.textContent = r.source || "—";
|
||||
src.appendChild(tag);
|
||||
const dt = document.createElement("td"); dt.className = "mono"; dt.textContent = fmtDate(r.created_at);
|
||||
tr.append(idx, em, src, dt);
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
$("statShown").textContent = list.length;
|
||||
$("foot").textContent = list.length
|
||||
? `${list.length} of ${all.length} shown` : "";
|
||||
}
|
||||
|
||||
async function load(){
|
||||
try {
|
||||
const res = await fetch("/admin/api/emails", { headers: { "Accept": "application/json" } });
|
||||
if (res.status === 401) { location.href = "/admin/login"; return; }
|
||||
const data = await res.json();
|
||||
all = data.emails || [];
|
||||
$("statTotal").textContent = data.count ?? all.length;
|
||||
$("statLatest").textContent = all.length ? fmtDate(all[0].created_at).slice(0,10) : "—";
|
||||
render();
|
||||
} catch (e) {
|
||||
$("empty").style.display = "block";
|
||||
$("empty").textContent = "Failed to load. " + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
$("search").addEventListener("input", render);
|
||||
$("refresh").addEventListener("click", load);
|
||||
load();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
72
waitlist-server/views/login.html
Normal file
@ -0,0 +1,72 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="robots" content="noindex, nofollow" />
|
||||
<title>Admin · Exbyte Depot</title>
|
||||
<style>
|
||||
:root{
|
||||
--bg:#0a0a0f; --panel:#12121b; --elevated:#1a1a24; --border:#26263340;
|
||||
--txt:#ececf4; --muted:#a2a2b4; --faint:#6d6d80;
|
||||
--acc:#7c6ef6; --acc-2:#9d93ff; --acc-deep:#5b4ee0; --del:#f2637e;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px;
|
||||
background:radial-gradient(1200px 600px at 50% -10%,#171626 0%,var(--bg) 55%);
|
||||
font:15px/1.5 -apple-system,Segoe UI,Roboto,Inter,system-ui,sans-serif;color:var(--txt)}
|
||||
.card{width:100%;max-width:380px;background:var(--panel);border:1px solid var(--border);
|
||||
border-radius:18px;padding:34px 30px;box-shadow:0 30px 80px -30px #000}
|
||||
.brand{display:flex;align-items:center;gap:11px;margin-bottom:24px}
|
||||
.logo{width:38px;height:38px;border-radius:11px;flex:0 0 auto;
|
||||
background:linear-gradient(145deg,var(--acc-2),var(--acc-deep));
|
||||
box-shadow:0 0 22px rgba(124,110,246,.5);display:grid;place-items:center}
|
||||
.logo svg{width:20px;height:20px}
|
||||
.brand b{font-size:15px;font-weight:650;letter-spacing:.2px}
|
||||
.brand span{display:block;font-size:11.5px;color:var(--faint);font-weight:500}
|
||||
h1{font-size:19px;font-weight:650;margin:0 0 4px}
|
||||
.sub{color:var(--muted);font-size:12.5px;margin:0 0 22px}
|
||||
label{display:block;font-size:11.5px;color:var(--muted);font-weight:600;margin:0 0 6px;
|
||||
text-transform:uppercase;letter-spacing:.4px}
|
||||
.field{margin-bottom:16px}
|
||||
input{width:100%;background:var(--elevated);border:1px solid var(--border);border-radius:11px;
|
||||
padding:11px 13px;color:var(--txt);font-size:14px;outline:none;transition:.15s}
|
||||
input:focus{border-color:var(--acc);box-shadow:0 0 0 3px rgba(124,110,246,.16)}
|
||||
button{width:100%;margin-top:6px;padding:12px;border:0;border-radius:11px;cursor:pointer;
|
||||
background:linear-gradient(145deg,var(--acc-2),var(--acc-deep));color:#fff;
|
||||
font-size:14.5px;font-weight:650;letter-spacing:.2px;transition:.15s}
|
||||
button:hover{filter:brightness(1.08)}
|
||||
.err{display:none;background:rgba(242,99,126,.1);border:1px solid rgba(242,99,126,.35);
|
||||
color:#ff9db0;font-size:12.5px;padding:9px 12px;border-radius:10px;margin-bottom:18px}
|
||||
.err.show{display:block}
|
||||
.foot{margin-top:20px;text-align:center;font-size:11px;color:var(--faint)}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<form class="card" method="POST" action="/admin/login" autocomplete="off">
|
||||
<div class="brand">
|
||||
<span class="logo"><svg viewBox="0 0 24 24" fill="none"><path d="M4 7l8-4 8 4v10l-8 4-8-4V7Z" stroke="#fff" stroke-width="1.6" stroke-linejoin="round"/><path d="M4 7l8 4 8-4M12 11v10" stroke="#fff" stroke-width="1.6" stroke-linejoin="round"/></svg></span>
|
||||
<div><b>Exbyte Depot</b><span>Waitlist admin</span></div>
|
||||
</div>
|
||||
<h1>Sign in</h1>
|
||||
<p class="sub">Restricted area — authorized access only.</p>
|
||||
<div class="err" id="err">Invalid username or password.</div>
|
||||
<div class="field">
|
||||
<label for="u">Username</label>
|
||||
<input id="u" name="username" type="text" autocomplete="username" required autofocus />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label for="p">Password</label>
|
||||
<input id="p" name="password" type="password" autocomplete="current-password" required />
|
||||
</div>
|
||||
<button type="submit">Sign in</button>
|
||||
<div class="foot">Protected endpoint · rate-limited</div>
|
||||
</form>
|
||||
<script>
|
||||
if (new URLSearchParams(location.search).get("error")) {
|
||||
document.getElementById("err").classList.add("show");
|
||||
history.replaceState(null, "", "/admin/login");
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||