Compare commits

...

3 commits

Author SHA1 Message Date
copilot-swe-agent[bot]
356d0963ae
feat: wire vrc-get-vpm live projects data into GPUI Projects view 2026-05-31 15:28:08 +00:00
copilot-swe-agent[bot]
04da12d85a
feat: add staged GPUI migration scaffold and shared runtime bridge 2026-05-31 15:10:29 +00:00
copilot-swe-agent[bot]
d2b91647d2
feat: scaffold GPUI migration workspace crates 2026-05-31 15:04:02 +00:00
12 changed files with 4892 additions and 183 deletions

4209
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -5,6 +5,8 @@ members = [
"xtask", "xtask",
"vrc-get", "vrc-get",
"vrc-get-gui", "vrc-get-gui",
"vrc-get-gui-gpui",
"vrc-get-gui-runtime",
"vrc-get-gui/windows-installer-wrapper", "vrc-get-gui/windows-installer-wrapper",
"vrc-get-vpm", "vrc-get-vpm",
] ]
@ -31,3 +33,6 @@ incremental = false
debug = 1 debug = 1
opt-level = 0 opt-level = 0
lto = "off" lto = "off"
[patch.crates-io]
gpui = { git = "https://github.com/zed-industries/zed.git", rev = "69e2130295c2649963eb639fc70b4f2ee8ea1624", package = "gpui" }

77
docs/gpui-migration.md Normal file
View file

@ -0,0 +1,77 @@
# GPUI staged migration track
This repository now contains a staged migration track to move GUI rendering from Tauri/WebView to GPUI without deleting the working Tauri app.
## Scope
- Keep `vrc-get-gui` (Tauri) as the production frontend until feature parity is reached.
- Add `vrc-get-gui-gpui` as an experimental frontend crate in the same workspace.
- Keep `vrc-get-vpm` as the shared business/backend library for both frontends.
- Introduce `vrc-get-gui-runtime` for a shared Tokio runtime bridge pattern.
## Stages
### Stage 1 Validated ✅
Package management table POC (`app/_main/projects/manage/-package-list-card.tsx` equivalent):
- GPUI table rendering with striped rows and column headers.
- Text input with clear button and live search filtering.
- Dialog lifecycle (title, confirm button, child content).
- Native file dialog integration via `rfd`.
- `TokioBridge` async plumbing (`spawn` / `call` / `shutdown`).
### Stage 2 In progress
Wire real `vrc-get-vpm` data into a live Projects list screen:
- `backend.rs` — async `load_projects()` using `VccDatabaseConnection`.
- `ProjectsView` — loading state → live data, live search filtering via `cx.observe`.
- `TokioBridge::call()` dispatches to Tokio; result is awaited in GPUI's async context via `cx.spawn`.
### Stage 3 Planned
Port pages in this order:
1. Setup wizard
2. Settings
3. Log viewer
4. Projects (full, with create/add/remove)
5. Packages (last, hardest)
## i18n migration
- Script added: `vrc-get-gui/scripts/i18next-to-rust-i18n.mjs`
- Converts i18next dotted-key JSON5 format to nested rust-i18n YAML.
- Run with:
- `npm run i18n:to-rust`
- `npm run i18n:to-rust -- locales/ja.json5 locales/ja.yml`
## Native file dialog policy
- GPUI migration path uses `rfd` for native file/folder dialogs on Windows/macOS/Linux.
## GPUI version pinning
- GPUI is pinned to Zed commit `69e2130295c2649963eb639fc70b4f2ee8ea1624` in workspace patch configuration.
- Update only by intentional SHA bumps.
## Linux GPU note
- GPUI with Vulkan generally behaves better than WebKit for open-source NVIDIA users.
- Nouveau may fall back to llvmpipe (software rendering).
- Mesa + AMD/Intel Vulkan is the expected reliable path.
## Async bridge pattern
```rust
// Dispatch heavy async work to Tokio; await result in GPUI.
let rx = self.bridge.call(load_projects()).unwrap();
cx.spawn(async move |this: WeakEntity<View>, cx: &mut AsyncApp| {
if let Ok(Ok(data)) = rx.await {
this.update(cx, |view, cx| {
view.data = data;
cx.notify();
}).ok();
}
}).detach();
```
The pattern works because `tokio::sync::oneshot::Receiver<T>` implements `Future` and can be awaited from within GPUI's executor.

View file

@ -0,0 +1,22 @@
[package]
name = "vrc-get-gui-gpui"
version = "0.1.0"
description = "Experimental GPUI frontend for vrc-get"
homepage.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
edition.workspace = true
[[bin]]
name = "vrc-get-gui-gpui"
path = "src/main.rs"
[dependencies]
anyhow = "1"
gpui = { git = "https://github.com/zed-industries/zed.git", rev = "69e2130295c2649963eb639fc70b4f2ee8ea1624", package = "gpui" }
gpui-component = "=0.5.1"
rfd = "0.15"
vrc-get-vpm = { path = "../vrc-get-vpm", features = ["experimental-project-management", "experimental-unity-management"] }
vrc-get-gui-runtime = { path = "../vrc-get-gui-runtime" }

View file

@ -0,0 +1,11 @@
# vrc-get-gui-gpui
Experimental GPUI frontend crate for staged migration.
Current focus:
1. Validate package-management-style table rendering.
2. Validate dialog lifecycle and text input behavior.
3. Validate native file dialog integration through `rfd`.
This crate intentionally coexists with the production `vrc-get-gui` (Tauri) crate.

View file

@ -0,0 +1,78 @@
use std::path::PathBuf;
use vrc_get_vpm::ProjectType;
use vrc_get_vpm::environment::{VccDatabaseConnection, UserProject};
use vrc_get_vpm::io::DefaultEnvironmentIo;
#[derive(Clone, Debug)]
pub struct ProjectRow {
pub name: String,
pub path: String,
pub project_type: String,
pub unity: String,
pub favorite: bool,
pub last_modified_ms: i64,
}
impl ProjectRow {
fn from_user_project(p: &UserProject) -> Option<Self> {
let path = p.path()?.to_owned();
let name = PathBuf::from(&path)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(&path)
.to_owned();
let project_type = project_type_label(p.project_type());
let unity = p
.unity_version()
.map(|v| v.to_string())
.unwrap_or_else(|| "unknown".to_owned());
let favorite = p.favorite();
let last_modified_ms = p
.last_modified()
.map(|d| d.as_unix_milliseconds())
.unwrap_or(0);
Some(ProjectRow {
name,
path,
project_type,
unity,
favorite,
last_modified_ms,
})
}
}
fn project_type_label(t: ProjectType) -> String {
match t {
ProjectType::Unknown => "Unknown",
ProjectType::LegacySdk2 => "Legacy SDK2",
ProjectType::LegacyWorlds => "Legacy Worlds",
ProjectType::LegacyAvatars => "Legacy Avatars",
ProjectType::UpmWorlds => "UPM Worlds",
ProjectType::UpmAvatars => "UPM Avatars",
ProjectType::UpmStarter => "UPM Starter",
ProjectType::Worlds => "Worlds",
ProjectType::Avatars => "Avatars",
ProjectType::VpmStarter => "VPM Starter",
}
.to_owned()
}
/// Load all projects from the VCC database. Intended to be called from a
/// Tokio context (via `TokioBridge::call`).
pub async fn load_projects() -> anyhow::Result<Vec<ProjectRow>> {
let io = DefaultEnvironmentIo::new_default();
let connection = VccDatabaseConnection::connect(&io).await?;
let mut projects = connection.get_projects();
projects.retain(|p| p.path().is_some());
let rows = projects
.iter()
.filter_map(ProjectRow::from_user_project)
.collect();
Ok(rows)
}

View file

@ -0,0 +1,267 @@
mod backend;
use backend::{ProjectRow, load_projects};
use gpui::prelude::*;
use gpui::{
App, Application, Context, IntoElement, ParentElement, Render, SharedString, Styled, Window,
WindowOptions, div,
};
use gpui_component::{
Root, StyledExt,
button::{Button, ButtonVariants as _},
h_flex,
input::{Input, InputState},
spinner::Spinner,
table::{Column, Table, TableDelegate, TableState},
v_flex,
};
use vrc_get_gui_runtime::TokioBridge;
// ---------------------------------------------------------------------------
// Projects table delegate
// ---------------------------------------------------------------------------
struct ProjectsDelegate {
columns: Vec<Column>,
rows: Vec<ProjectRow>,
}
impl ProjectsDelegate {
fn new() -> Self {
Self {
columns: vec![
Column::new("name", "Name"),
Column::new("type", "Type"),
Column::new("unity", "Unity"),
Column::new("path", "Path"),
],
rows: vec![],
}
}
fn set_rows(&mut self, rows: Vec<ProjectRow>) {
self.rows = rows;
}
}
impl TableDelegate for ProjectsDelegate {
fn columns_count(&self, _: &App) -> usize {
self.columns.len()
}
fn rows_count(&self, _: &App) -> usize {
self.rows.len()
}
fn column(&self, col_ix: usize, _: &App) -> &Column {
&self.columns[col_ix]
}
fn render_td(
&mut self,
row_ix: usize,
col_ix: usize,
_: &mut Window,
_: &mut Context<TableState<Self>>,
) -> impl IntoElement {
let row = &self.rows[row_ix];
let value: SharedString = match col_ix {
0 => row.name.clone().into(),
1 => row.project_type.clone().into(),
2 => row.unity.clone().into(),
_ => row.path.clone().into(),
};
div().child(value)
}
}
// ---------------------------------------------------------------------------
// Loading state
// ---------------------------------------------------------------------------
enum ProjectsData {
Loading,
Loaded(Vec<ProjectRow>),
Error(String),
}
// ---------------------------------------------------------------------------
// Root view
// ---------------------------------------------------------------------------
struct ProjectsView {
bridge: TokioBridge,
search_input: gpui::Entity<InputState>,
table_state: gpui::Entity<TableState<ProjectsDelegate>>,
data: ProjectsData,
}
impl ProjectsView {
fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let bridge = TokioBridge::new("vrc-get-gpui-runtime");
let search_input =
cx.new(|cx| InputState::new(window, cx).placeholder("Search projects"));
let table_state = cx.new(|cx| TableState::new(ProjectsDelegate::new(), window, cx));
// Re-filter the table whenever the search input changes.
cx.observe(&search_input, |view, _, cx| {
view.apply_search(cx);
})
.detach();
let mut view = Self {
bridge,
search_input,
table_state,
data: ProjectsData::Loading,
};
view.reload(cx);
view
}
fn reload(&mut self, cx: &mut Context<Self>) {
self.data = ProjectsData::Loading;
cx.notify();
let rx = self
.bridge
.call(load_projects())
.expect("tokio bridge still alive");
cx.spawn(async move |this: gpui::WeakEntity<ProjectsView>, cx: &mut gpui::AsyncApp| {
match rx.await {
Ok(Ok(rows)) => {
this.update(cx, |view, cx| {
let search = view.search_input.read(cx).value().to_lowercase();
let filtered = filter_rows(&rows, &search);
view.table_state.update(cx, |table, _| {
table.delegate_mut().set_rows(filtered);
});
view.data = ProjectsData::Loaded(rows);
cx.notify();
})
.ok();
}
Ok(Err(err)) => {
this.update(cx, |view, cx| {
view.data = ProjectsData::Error(err.to_string());
cx.notify();
})
.ok();
}
Err(_) => {}
}
})
.detach();
}
fn apply_search(&mut self, cx: &mut Context<Self>) {
let ProjectsData::Loaded(ref all_rows) = self.data else {
return;
};
let search = self.search_input.read(cx).value().to_lowercase();
let rows = filter_rows(all_rows, &search);
self.table_state.update(cx, |table, _| {
table.delegate_mut().set_rows(rows);
});
cx.notify();
}
}
fn filter_rows(rows: &[ProjectRow], search: &str) -> Vec<ProjectRow> {
if search.is_empty() {
rows.to_vec()
} else {
rows.iter()
.filter(|r| {
r.name.to_lowercase().contains(search)
|| r.path.to_lowercase().contains(search)
})
.cloned()
.collect()
}
}
impl Render for ProjectsView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let is_loading = matches!(self.data, ProjectsData::Loading);
let error_msg: Option<SharedString> = if let ProjectsData::Error(ref e) = self.data {
Some(e.clone().into())
} else {
None
};
// Toolbar
let search_el = Input::new(&self.search_input).cleanable(true);
let reload_btn = Button::new("reload")
.label("Reload")
.on_click(cx.listener(|view, _event: &gpui::ClickEvent, _window, cx| {
view.reload(cx);
}));
let add_btn = Button::new("add-project")
.primary()
.label("Add Project")
.on_click(|_event, _window, _cx| {
let _ = rfd::FileDialog::new().pick_folder();
});
let toolbar = h_flex()
.items_center()
.justify_between()
.child(search_el)
.child(h_flex().gap_2().child(reload_btn).child(add_btn));
// Body
let body: gpui::AnyElement = if is_loading {
h_flex()
.size_full()
.items_center()
.justify_center()
.gap_2()
.child(Spinner::new())
.child(div().child("Loading projects…"))
.into_any_element()
} else if let Some(msg) = error_msg {
div()
.p_4()
.text_color(gpui::red())
.child(format!("Error: {msg}"))
.into_any_element()
} else {
Table::new(&self.table_state)
.stripe(true)
.into_any_element()
};
v_flex()
.size_full()
.p_4()
.gap_3()
.child(
h_flex()
.items_center()
.gap_2()
.child(div().font_bold().child("Projects"))
.when(is_loading, |el| el.child(Spinner::new())),
)
.child(toolbar)
.child(body)
}
}
fn main() {
Application::new().run(|cx: &mut App| {
gpui_component::init(cx);
cx.open_window(WindowOptions::default(), |window, cx| {
let view = cx.new(|cx| ProjectsView::new(window, cx));
cx.new(|cx| Root::new(view, window, cx))
})
.expect("opening gpui window");
cx.activate(true);
});
}

View file

@ -0,0 +1,14 @@
[package]
name = "vrc-get-gui-runtime"
version = "0.1.0"
edition.workspace = true
license.workspace = true
authors.workspace = true
homepage.workspace = true
repository.workspace = true
[dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "sync"] }
[dev-dependencies]
tokio = { version = "1", features = ["macros", "rt", "time"] }

View file

@ -0,0 +1,135 @@
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
use tokio::runtime::Builder;
use tokio::sync::{mpsc, oneshot};
type BoxFuture = Pin<Box<dyn Future<Output = ()> + Send + 'static>>;
enum Message {
Task(BoxFuture),
Shutdown,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BridgeClosed;
impl std::fmt::Display for BridgeClosed {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("tokio bridge is closed")
}
}
impl std::error::Error for BridgeClosed {}
#[derive(Clone)]
pub struct TokioBridge {
sender: mpsc::UnboundedSender<Message>,
closed: Arc<AtomicBool>,
}
impl TokioBridge {
pub fn new(thread_name: &'static str) -> Self {
let (sender, mut receiver) = mpsc::unbounded_channel();
let closed = Arc::new(AtomicBool::new(false));
let closed_for_thread = closed.clone();
thread::Builder::new()
.name(thread_name.to_owned())
.spawn(move || {
let runtime = Builder::new_multi_thread()
.worker_threads(1)
.enable_all()
.build()
.expect("building tokio bridge runtime");
runtime.block_on(async move {
while let Some(message) = receiver.recv().await {
match message {
Message::Task(task) => {
tokio::spawn(task);
}
Message::Shutdown => break,
}
}
closed_for_thread.store(true, Ordering::Release);
});
})
.expect("spawning tokio bridge thread");
Self { sender, closed }
}
pub fn is_closed(&self) -> bool {
self.closed.load(Ordering::Acquire)
}
pub fn spawn<Fut>(&self, task: Fut) -> Result<(), BridgeClosed>
where
Fut: Future<Output = ()> + Send + 'static,
{
self.sender
.send(Message::Task(Box::pin(task)))
.map_err(|_| BridgeClosed)
}
pub fn call<Fut, T>(&self, task: Fut) -> Result<oneshot::Receiver<T>, BridgeClosed>
where
Fut: Future<Output = T> + Send + 'static,
T: Send + 'static,
{
let (sender, receiver) = oneshot::channel();
self.spawn(async move {
let _ = sender.send(task.await);
})?;
Ok(receiver)
}
pub fn shutdown(&self) -> Result<(), BridgeClosed> {
self.sender
.send(Message::Shutdown)
.map_err(|_| BridgeClosed)
}
}
#[cfg(test)]
mod tests {
use super::TokioBridge;
use std::time::Duration;
use tokio::time::timeout;
#[tokio::test(flavor = "current_thread")]
async fn call_returns_result() {
let bridge = TokioBridge::new("test-runtime");
let receiver = bridge.call(async { 40 + 2 }).unwrap();
assert_eq!(receiver.await.unwrap(), 42);
bridge.shutdown().unwrap();
}
#[tokio::test(flavor = "current_thread")]
async fn spawn_runs_in_background() {
let bridge = TokioBridge::new("test-runtime-bg");
let receiver = bridge
.call(async {
tokio::time::sleep(Duration::from_millis(10)).await;
"done"
})
.unwrap();
assert_eq!(
timeout(Duration::from_secs(1), receiver)
.await
.unwrap()
.unwrap(),
"done"
);
bridge.shutdown().unwrap();
}
}

View file

@ -24,6 +24,7 @@ serde = { version = "1", features = ["derive"] }
serde_with = { version = "3", features = ["base64"] } serde_with = { version = "3", features = ["base64"] }
tauri = { version = "=2.11.2", features = [ "config-toml" ] } # = for sync version between npm and cargo tauri = { version = "=2.11.2", features = [ "config-toml" ] } # = for sync version between npm and cargo
vrc-get-vpm = { path = "../vrc-get-vpm", features = ["experimental-project-management", "experimental-unity-management"] } vrc-get-vpm = { path = "../vrc-get-vpm", features = ["experimental-project-management", "experimental-unity-management"] }
vrc-get-gui-runtime = { path = "../vrc-get-gui-runtime" }
reqwest = { version = "0.13", features = ["gzip", "brotli", "json"] } reqwest = { version = "0.13", features = ["gzip", "brotli", "json"] }
specta = { version = "2.0.0-rc.24", features = [ "chrono", "url", "indexmap" ] } specta = { version = "2.0.0-rc.24", features = [ "chrono", "url", "indexmap" ] }
tauri-specta = { version = "2.0.0-rc.24", features = ["typescript"] } tauri-specta = { version = "2.0.0-rc.24", features = ["typescript"] }

View file

@ -9,6 +9,7 @@
"dev": "vite", "dev": "vite",
"build": "npm install && npm run build:vite", "build": "npm install && npm run build:vite",
"build:vite": "vite build", "build:vite": "vite build",
"i18n:to-rust": "node scripts/i18next-to-rust-i18n.mjs",
"format": "biome format", "format": "biome format",
"check": "biome check", "check": "biome check",
"lint": "tsc && biome lint" "lint": "tsc && biome lint"

View file

@ -0,0 +1,49 @@
#!/usr/bin/env node
import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import JSON5 from "json5";
function toYaml(value, indent = 0) {
const pad = " ".repeat(indent);
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return `${JSON.stringify(String(value))}`;
}
return Object.entries(value)
.map(([key, child]) => {
const escapedKey = `'${key.replaceAll("'", "''")}'`;
if (typeof child === "object" && child !== null && !Array.isArray(child)) {
const nested = toYaml(child, indent + 1);
return `${pad}${escapedKey}:\n${nested}`;
}
return `${pad}${escapedKey}: ${JSON.stringify(String(child))}`;
})
.join("\n");
}
async function main() {
const inputPath = process.argv[2] ?? "locales/en.json5";
const outputPath =
process.argv[3] ??
path.join(
path.dirname(inputPath),
`${path.basename(inputPath, path.extname(inputPath))}.yml`,
);
const source = await readFile(inputPath, "utf8");
const parsed = JSON5.parse(source);
const translationRoot = parsed.translation ?? parsed;
if (typeof translationRoot !== "object" || translationRoot === null) {
throw new Error("Expected object at root or translation");
}
const yaml = `${toYaml(translationRoot)}\n`;
await writeFile(outputPath, yaml, "utf8");
console.log(`Converted ${inputPath} -> ${outputPath}`);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});