Compare commits

..

5 commits

Author SHA1 Message Date
anatawa12
ff5dad76e6
ci: reduce size of test script 2025-06-21 13:43:05 +09:00
anatawa12
a98a0d597b
ci: update script (add log) 2025-06-21 13:39:55 +09:00
anatawa12
061d2a3049
ci: update script 2025-06-21 13:33:18 +09:00
anatawa12
f65dfeca1c
ci: update script 2025-06-21 13:25:23 +09:00
anatawa12
b256be46cf
ci: test apple script 2025-06-21 13:12:05 +09:00
268 changed files with 9641 additions and 17070 deletions

View file

@ -1,2 +0,0 @@
[alias]
xtask = "run --profile xtask --target host-tuple -p xtask --"

2
.github/FUNDING.yml vendored
View file

@ -1,2 +0,0 @@
github: [anatawa12]
custom: [https://booth.pm/ja/items/6448396]

View file

@ -1,50 +0,0 @@
name: 'Sign in place'
description: 'Signs exe in-place'
inputs:
signpath-api-token:
description: SignPath REST API access token.
required: true
signing-policy-slug:
description: SignPath signing policy slug
version:
required: true
description: Version number of artifact we're signing
path:
description: The path of artifact we'll sign
required: true
artifact-name:
description: The name of artifact on github
required: true
runs:
using: "composite"
steps:
- name: upload unsigned artifact
id: upload-unsigned-artifact
uses: actions/upload-artifact@v4
with:
path: ${{ inputs.path }}
name: ${{ inputs.artifact-name }}
- name: Sign on signpath
id: sign
uses: signpath/github-action-submit-signing-request@v2
with:
api-token: ${{ inputs.signpath-api-token }}
organization-id: 'c2d4dbf9-920f-4318-9017-7306e0fc7590'
project-slug: 'vrc-get'
signing-policy-slug: ${{ inputs.signing-policy-slug }}
github-artifact-id: '${{ steps.upload-unsigned-artifact.outputs.artifact-id }}'
wait-for-completion: true
output-artifact-directory: .github/actions/sign-windows/signed/${{ inputs.artifact-name }}
parameters: |
version: ${{ toJSON(inputs.version) }}
- name: Copy artifact
env:
DOWNLOADED_PATH: .github/actions/sign-windows/signed/${{ inputs.artifact-name }}
REPLACE_TO: ${{ inputs.path }}
shell: bash
run:
cp -f "$DOWNLOADED_PATH/$(basename "$REPLACE_TO")" "$REPLACE_TO"

View file

@ -1,8 +0,0 @@
If you're writing code:
- Please don't make localization for locales other than en / ja. I cannot review those locales.
- Run cargo clippy for lints and cargo fmt for format before commit.
- After completing the code and commit, please add a changelog entry. Please note that the numbers in the changelog file are pull request numbers, not issue numbers.
- Please add it to the bottom of the change list.
- Please use the proper section for each change. "Fix" should be used only for bug fixes. UX improvements typically belong under "Change", and new features typically under "Add". These are not strict rules, so use them flexibly.
- You should use Conventional Commits (chore:, fix:, dev:, build:, docs:, style:, lint:, and others).
- Please split commits for implementation and changelog updates.

View file

@ -55,21 +55,9 @@ module.exports = async ({github, context}) => {
},
];
/** @type {{missingCount: number, extraCount: number, id: string, discussionNumber: number}[]} */
const localeData = [];
for (const locale of locales) {
const proceed = await processOneLocale(github, owner, repo, locale.discussionNumber, locale.replyId, locale.id);
localeData.push({
id: locale.id,
discussionNumber: locale.discussionNumber,
missingCount: proceed.missingCount,
extraCount: proceed.extraCount,
})
await processOneLocale(github, owner, repo, locale.discussionNumber, locale.replyId, locale.id);
}
// 894 is English Localization and Text Representation
await updateRootLocale(github, owner, repo, 894, localeData);
}
/**
@ -80,113 +68,9 @@ module.exports = async ({github, context}) => {
* @param number {number}
* @param replyToId {string}
* @param localeId {string}
* @return {Promise<{missingCount: number, extraCount: number}>}
* @return {Promise<void>}
*/
async function processOneLocale(github, owner, repo, number, replyToId, localeId) {
const enJson = json5.parse(await fs.readFile(`vrc-get-gui/locales/en.json5`, "utf8"));
const enKeys = normalizeKeys(Object.keys(enJson.translation));
const transJson = json5.parse(await fs.readFile(`vrc-get-gui/locales/${localeId}.json5`, "utf8"));
const transKeys = normalizeKeys(Object.keys(transJson.translation));
const {missingList: missingKeys, extraList: extraKeys} = missingAndExtras(enKeys, transKeys);
const newData = {
missingKeys,
extraKeys,
};
const newAutoPart = `**Missing Keys:**\n${listToMarkdown(missingKeys)}\n\n**Excess Keys:**\n${listToMarkdown(extraKeys)}\n`;
const {discussionId, previousJson: dataJson} = await updateComment(github, owner, repo, number, newAutoPart, newData);
dataJson.missingKeys ??= [];
dataJson.extraKeys ??= [];
// create comment if there are new missing / extra keys
const {extraList: newlyAddedMissingKeys} = missingAndExtras(normalizeKeys(dataJson.missingKeys), missingKeys);
const {extraList: newlyAddedExtraKeys} = missingAndExtras(normalizeKeys(dataJson.extraKeys), extraKeys);
if (newlyAddedMissingKeys.length > 0 || newlyAddedExtraKeys.length > 0) {
const text = `
There are new missing / excess keys in the translation. Please update the translation!
**New Missing Keys:**
${listToMarkdown(newlyAddedMissingKeys)}
**New Excess Keys:**
${listToMarkdown(newlyAddedExtraKeys)}
`
await github.graphql(`
mutation($discussionId: ID!, $replyToId: ID!, $body: String!) {
addDiscussionComment(input: {discussionId: $discussionId, replyToId: $replyToId, body: $body}) {
comment {
body
}
}
}
`, {discussionId, replyToId, body: text});
}
return {
missingCount: missingKeys.length,
extraCount: extraKeys.length,
}
}
/**
* Updates the root locale configuration for a specified repository.
*
* @param {import('@octokit/rest').Octokit} github - The GitHub API client instance used to interact with the GitHub API.
* @param {string} owner - The owner of the repository where the root locale is to be updated.
* @param {string} repo - The name of the repository where the root locale is to be updated.
* @param {number} number
* @param {{missingCount: number, extraCount: number, id: string, discussionNumber: number}[]} localeData - The locale data object containing the updated root locale configuration.
* @return {Promise<void>} A promise that resolves to the API response for the update operation.
*/
async function updateRootLocale(github, owner, repo, number, localeData) {
let table = "| locale | missing count | exceeding count | link |\n" +
"| -- | -- | -- | -- |\n"
for (let {missingCount, extraCount, id, discussionNumber} of localeData) {
table += `| ${id} | ${missingCount} | ${extraCount} | [link](https://github.com/${owner}/${repo}/discussions/${discussionNumber}) |\n`;
}
await updateComment(github, owner, repo, number, table, {});
}
/**
* @template T
* @param beforeList {T[]}
* @param afterList {T[]}
* @return {{missingList: T[], extraList: T[]}}
*/
function missingAndExtras(beforeList, afterList) {
const missingList = beforeList.filter(key => !afterList.includes(key)).filter(key => !optionalKeys.includes(key));
const extraList = afterList.filter(key => !beforeList.includes(key)).filter(key => !optionalKeys.includes(key));
return {missingList, extraList};
}
function listToMarkdown(values) {
return values.length === 0 ? 'nothing' : values.map(key => `- \`${key}\``).join('\n')
}
/**
*
* @param github {import('@octokit/rest').Octokit}
* @param owner {string}
* @param repo {string}
* @param number {number}
* @param content {string} the updated content
* @param newData {object} data stored in the comment
* @return {Promise<{previousJson: object, discussionId:string}>}
*/
async function updateComment(
github,
owner, repo, number,
content,
newData,
) {
/** @type {{data: {repository: {discussion: {body: string}}}}} */
const result = await github.graphql(`
query($owner: String!, $repo: String!, $number: Int!) {
@ -214,14 +98,42 @@ async function updateComment(
const postAutoPart = split1[1] ?? '';
const dataJsonLine = autoPart.split(/\r?\n/).find(l => l.startsWith(dataJsonLinePrefix));
const previousJson = dataJsonLine ? JSON.parse(dataJsonLine.slice(dataJsonLinePrefix.length)) : {};
// the dataJson is for computing the difference and create new comment if there are changes
const dataJson = dataJsonLine ? JSON.parse(dataJsonLine.slice(dataJsonLinePrefix.length)) : {};
dataJson.missingKeys ??= [];
dataJson.extraKeys ??= [];
const enJson = json5.parse(await fs.readFile(`vrc-get-gui/locales/en.json5`, "utf8"));
const enKeys = normalizeKeys(Object.keys(enJson.translation));
const transJson = json5.parse(await fs.readFile(`vrc-get-gui/locales/${localeId}.json5`, "utf8"));
const transKeys = normalizeKeys(Object.keys(transJson.translation));
const missingKeys = enKeys.filter(key => !transKeys.includes(key)).filter(key => !optionalKeys.includes(key));
const extraKeys = transKeys.filter(key => !enKeys.includes(key)).filter(key => !optionalKeys.includes(key));
const missingKeysStr = missingKeys.length === 0 ? 'nothing' : missingKeys.map(key => `- \`${key}\``).join('\n');
const excessKeysStr = extraKeys.length === 0 ? 'nothing' : extraKeys.map(key => `- \`${key}\``).join('\n');
const newData = {
missingKeys,
extraKeys,
};
const newAutoPart = `
**Missing Keys:**
${missingKeysStr}
**Excess Keys:**
${excessKeysStr}
const newBody = `${manualPart}${autoPartStart}
${content}
<!-- data part
${dataJsonLinePrefix}${JSON.stringify(newData)}
-->
${autoPartEnd}${postAutoPart}`;
`;
const newBody = `${manualPart}${autoPartStart}${newAutoPart}${autoPartEnd}${postAutoPart}`;
await github.graphql(`
mutation($discussionId: ID!, $body: String!) {
@ -233,9 +145,36 @@ ${autoPartEnd}${postAutoPart}`;
}
`, {discussionId, body: newBody});
return {
previousJson,
discussionId,
// create comment if there are new missing / extra keys
const oldMissingKeys = new Set(normalizeKeys(dataJson.missingKeys));
const oldExtraKeys = new Set(normalizeKeys(dataJson.extraKeys));
const newlyAddedMissingKeys = missingKeys.filter(key => !oldMissingKeys.has(key));
const newlyAddedExtraKeys = extraKeys.filter(key => !oldExtraKeys.has(key));
if (newlyAddedMissingKeys.length > 0 || newlyAddedExtraKeys.length > 0) {
const newMissingKeysStr = newlyAddedMissingKeys.length === 0 ? 'nothing' : newlyAddedMissingKeys.map(key => `- \`${key}\``).join('\n');
const newExcessKeysStr = newlyAddedExtraKeys.length === 0 ? 'nothing' : newlyAddedExtraKeys.map(key => `- \`${key}\``).join('\n');
const text = `
There are new missing / excess keys in the translation. Please update the translation!
**New Missing Keys:**
${newMissingKeysStr}
**New Excess Keys:**
${newExcessKeysStr}
`
await github.graphql(`
mutation($discussionId: ID!, $replyToId: ID!, $body: String!) {
addDiscussionComment(input: {discussionId: $discussionId, replyToId: $replyToId, body: $body}) {
comment {
body
}
}
}
`, {discussionId, replyToId, body: text});
}
}

View file

@ -4,9 +4,12 @@ on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
env:
CARGO_TERM_COLOR: always
# workaround of https://github.com/tauri-apps/tauri-action/issues/1091
TAURI_BUNDLER_DMG_IGNORE_CI: false
jobs:
build-gui:
@ -16,7 +19,6 @@ jobs:
include:
- triple: x86_64-unknown-linux-gnu
on: ubuntu-22.04
bundles: appimage,appimage-updater
setup: |
sudo apt update && sudo apt install -y lld
ld.lld --version
@ -26,14 +28,12 @@ jobs:
- triple: x86_64-pc-windows-msvc
on: windows-latest
bundles: setup-exe,setup-exe-zip,exe-updater
- triple: universal-apple-darwin
on: macos-14
setup: |
rustup target add aarch64-apple-darwin
rustup target add x86_64-apple-darwin
bundles: app,dmg,app-updater
triple:
- x86_64-unknown-linux-gnu
#- aarch64-unknown-linux-gnu
@ -46,22 +46,7 @@ jobs:
RUSTFLAGS: ${{ matrix.rustflags }}
steps:
- uses: samypr100/setup-dev-drive@v4
with:
drive-size: 12GB # github actions grantees 14 GB of disk space. we have few GB for action environment
drive-path: "/dev_drive.vhdx"
mount-path: ${{ github.workspace }}
trusted-dev-drive: true
- name: Preinitialize git repository
shell: bash
run: |
# hopefully for non-cleaning environments, actions/checkout will tries to clean existing repository
# if existing dir is not git repository nor for specified repository.
# this step creates git directory to workaround the behavior
git init
git remote add origin "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY"
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
submodules: recursive
- run: rustup update stable
@ -74,7 +59,7 @@ jobs:
with:
key: ci-build-gui-${{ matrix.triple }}
- name: Cache javascript essentials
uses: actions/cache@v5
uses: actions/cache@v4
with:
path: |
~/.npm
@ -102,16 +87,20 @@ jobs:
cp vrc-get-gui/Tauri.toml vrc-get-gui/Tauri.toml.bak
grep -v "remove if ci" < vrc-get-gui/Tauri.toml.bak > vrc-get-gui/Tauri.toml
- name: Build ALCOM binary
run:
cargo xtask build-alcom --release --target ${{ matrix.triple }} --devtools ${{ (secrets.ACTIONS_STEP_DEBUG || vars.ACTIONS_STEP_DEBUG) == 'true' && '--verbose' || '' }}
- name: Build installer
- name: Enable Devtools Feature
shell: bash
run: cargo xtask bundle-alcom --release --target ${{ matrix.triple }} --bundles ${{ matrix.bundles }}
run: |
cargo add --package vrc-get-gui tauri --features devtools
- uses: tauri-apps/tauri-action@v0
with:
projectPath: vrc-get-gui
tauriScript: npm run tauri
args: |
--target ${{ matrix.triple }} -c '{"version":"${{ steps.version.outputs.version }}", "bundle":{"windows":{"certificateThumbprint": null}}}' ${{ (secrets.ACTIONS_STEP_DEBUG || vars.ACTIONS_STEP_DEBUG) == 'true' && '--verbose' || '' }}
- name: Upload built binary
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.triple }}
path: |
@ -122,195 +111,6 @@ jobs:
target/${{ matrix.triple }}/release/bundle/*/ALCOM*
target/${{ matrix.triple }}/release/bundle/*/alcom*
build-rpm:
strategy:
fail-fast: false
matrix:
include:
- install_rust: false
- no_dist: false
- mock-env: fedora-40-x86_64
install_rust: true
no_dist: true
mock-env:
- fedora-40-x86_64
- fedora-rawhide-x86_64
runs-on: ubuntu-latest
container:
image: 'fedora:latest'
options: --privileged
env:
MOCK_ENV: ${{ matrix.mock-env }}
RPMBUILD_OPTS: ${{ case(matrix.no_dist, '-D "dist %{nil}"', '') }} ${{ case(matrix.install_rust, '-D "install_rust 1"', '') }}
steps:
- name: Install CI dependencies
run: dnf install -y git tar curl
- uses: actions/checkout@v6
with:
submodules: recursive
# https://github.com/actions/checkout/issues/1169
- run: git config --system --add safe.directory $GITHUB_WORKSPACE
- name: install dependencies
run: dnf install -y mock rpmbuild
- name: prepare rpm build environment
run: mkdir -p ~/rpmbuild/{BUILD,RPMS,SOURCES,SPECS,SRPMS}
- name: update version name in spec file
run: |
COMMIT_HASH="$(git rev-parse HEAD)"
SHORT_HASH="$(git rev-parse --short HEAD)"
PKG_VERSION="$(<vrc-get-gui/Cargo.toml sed -En -e '/^version/{s/.*"(.*)".*/\1/p;}')+${SHORT_HASH}"
echo "PKG_VERSION=$PKG_VERSION" >> $GITHUB_ENV
cp vrc-get-gui/Cargo.toml vrc-get-gui/Cargo.toml.bak
sed -E "/^version/s/\"$/+$(git rev-parse --short HEAD)\"/" < vrc-get-gui/Cargo.toml.bak > vrc-get-gui/Cargo.toml
rm vrc-get-gui/Cargo.toml.bak
git add vrc-get-gui/Cargo.toml
sed -i vrc-get-gui/bundle/alcom.spec -e "/^Version:/c\Version: ${PKG_VERSION//-/\~}"
- name: build source rpm package
run: |
git archive --format=tar --prefix=vrc-get-gui-v$PKG_VERSION/ $(git write-tree) | gzip > ~/rpmbuild/SOURCES/gui-v$PKG_VERSION.tar.gz
eval "rpmbuild -bs vrc-get-gui/bundle/alcom.spec $RPMBUILD_OPTS"
- name: build rpm package
run: eval "mock -v -r '$(ls -1 /etc/mock{/eol,}/$MOCK_ENV.cfg 2>/dev/null)' --enable-network $RPMBUILD_OPTS rebuild ~/rpmbuild/SRPMS/alcom-${PKG_VERSION//-/\~}-1*.src.rpm"
- name: copy built binaries
run: |
mkdir -p artifacts
cp ~/rpmbuild/SRPMS/alcom-${PKG_VERSION//-/\~}-1*.src.rpm artifacts/
cp /var/lib/mock/$MOCK_ENV/result/alcom-${PKG_VERSION//-/\~}-1*.${MOCK_ENV##*-}.rpm artifacts/
- name: Upload built binary
uses: actions/upload-artifact@v7
with:
name: rpm-${{ matrix.mock-env }}
path: artifacts/*
build-deb:
strategy:
fail-fast: false
matrix:
include:
- install_rust: false
- install_nodejs: false
- apt-components: main
- apt-with-updates: false
# Old distributions have older tools than we need. Download tools in build process
- pbuilder-distribution: bookworm
install_rust: true
install_nodejs: true
- pbuilder-distribution: jammy
install_rust: true
install_nodejs: true
# Debian uses mirror from debian-archive.trafficmanager.net which is managed by microsoft on azure
- pbuilder-distribution: bookworm
mirror: http://debian-archive.trafficmanager.net/debian/
keyring: /usr/share/keyrings/debian-archive-keyring.gpg
- pbuilder-distribution: sid
mirror: http://debian-archive.trafficmanager.net/debian/
keyring: /usr/share/keyrings/debian-archive-keyring.gpg
# Ubuntu legacy release
- pbuilder-distribution: jammy
mirror: http://archive.ubuntu.com/ubuntu/
apt-components: main universe
apt-with-updates: true
keyring: /usr/share/keyrings/ubuntu-archive-keyring.gpg
# For note,
# bookworm: libc6@2.36
# sid: libc6@2.39 as of 2026/06/14
# jammy: libc6@2.35
pbuilder-distribution:
# debian distribution
- bookworm
- sid
# ubuntu distribution
- jammy # jammy is the oldest ubuntu release with libwebkit2gtk-4.1 >= 2.41 (but requires -updates and universe)
target-arch:
- amd64
runs-on: ubuntu-latest
env:
TARGET_ARCH: ${{ matrix.target-arch }}
PBUILDER_DISTRIBUTION: ${{ matrix.pbuilder-distribution }}
PBUILDER_MIRROR: ${{ matrix.mirror }}
PBUILDER_KEYRING: ${{ matrix.keyring }}
PBUILDER_COMPONENTS: ${{ matrix.apt-components }}
PBUILDER_OTHERMIRROR: ${{ case(matrix.apt-with-updates, format('deb {0} {1}-updates {2}', matrix.mirror, matrix.pbuilder-distribution, matrix.apt-components), '') }}
INSTALL_RUST: ${{ case(matrix.install_rust, '1', '0') }}
INSTALL_NODEJS: ${{ case(matrix.install_nodejs, '1', '0') }}
steps:
- uses: actions/checkout@v6
with:
path: vrc-get
submodules: recursive
- name: install dependencies
run: sudo apt update && sudo apt install -y pbuilder debian-archive-keyring debhelper-compat=13
- name: prepare deb build environment
working-directory: vrc-get
run: |
cp -r vrc-get-gui/bundle/debian debian
sudo pbuilder create \
--architecture "$TARGET_ARCH" \
--keyring "$PBUILDER_KEYRING" \
--mirror "$PBUILDER_MIRROR" \
--distribution "$PBUILDER_DISTRIBUTION" \
--components "$PBUILDER_COMPONENTS" \
--othermirror "$PBUILDER_OTHERMIRROR"
- name: update changelog
working-directory: vrc-get
run: |
COMMIT_HASH="$(git rev-parse HEAD)"
SHORT_HASH="$(git rev-parse --short HEAD)"
PKG_VERSION="$(<vrc-get-gui/Cargo.toml sed -En -e '/^version/{s/.*"(.*)".*/\1/p;}')+${SHORT_HASH}"
echo "PKG_VERSION=$PKG_VERSION" >> $GITHUB_ENV
cp debian/changelog debian/changelog.bak
cat - debian/changelog.bak <<CHANGELOG > debian/changelog
alcom (${PKG_VERSION//-/\~}-1) experimental;
* Upgraded version to ${PKG_VERSION}
-- anatawa12 <i@anatawa12.com> $(date -u +"%a, %d %b %Y %H:%M:%S +0000")
CHANGELOG
rm debian/changelog.bak
cp vrc-get-gui/Cargo.toml vrc-get-gui/Cargo.toml.bak
sed -E "/^version/s/\"$/+$(git rev-parse --short HEAD)\"/" < vrc-get-gui/Cargo.toml.bak > vrc-get-gui/Cargo.toml
rm vrc-get-gui/Cargo.toml.bak
git add vrc-get-gui/Cargo.toml
echo cat debian/changelog
cat debian/changelog
dpkg-parsechangelog
- name: build source deb package
working-directory: vrc-get
run: |
git archive --format=tar $(git write-tree) | xz > ../alcom_${PKG_VERSION//-/\~}.orig.tar.xz
dpkg-buildpackage -d -S
- name: build deb package
working-directory: vrc-get
run: |
sudo --preserve-env=INSTALL_RUST,INSTALL_NODEJS pbuilder build --use-network yes ../alcom_${PKG_VERSION//-/\~}-1.dsc
- name: copy built binaries
run: |
mkdir -p artifacts
cp vrc-get/debian/changelog artifacts/
cp /var/cache/pbuilder/result/alcom_${PKG_VERSION//-/\~}* artifacts/
ls artifacts
- name: Print information about built package
run: dpkg-deb -I artifacts/alcom_${PKG_VERSION//-/\~}-1_$TARGET_ARCH.deb
- name: Upload built binary
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7
with:
name: deb-${{ matrix.pbuilder-distribution }}-${{ matrix.target-arch }}
path: artifacts/*
conclude-gui:
runs-on: ubuntu-latest
if: ${{ always() }}

View file

@ -4,6 +4,7 @@ on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
env:
CARGO_TERM_COLOR: always
@ -71,9 +72,11 @@ jobs:
runs-on: ${{ matrix.on }}
env:
RUSTFLAGS: ${{ matrix.rustflags }}
# workaround of https://github.com/tauri-apps/tauri-action/issues/1091
TAURI_BUNDLER_DMG_IGNORE_CI: false
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: dtolnay/rust-toolchain@stable
@ -99,7 +102,7 @@ jobs:
- name: Build
run: cargo build --verbose --target ${{ matrix.triple }}
- name: Upload built binary
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.triple }}
path: |
@ -113,10 +116,10 @@ jobs:
- name: Check binary is statically linked
shell: bash
if: ${{ matrix.static-linked }}
env:
RUSTFLAGS: ''
run: |
cargo xtask check-static-link target/${{ matrix.triple }}/debug/vrc-get${WINDIR:+.exe}
# https://github.com/taiki-e/setup-cross-toolchain-action/issues/18
unset CARGO_BUILD_TARGET
cargo run -p build-check-static-link target/${{ matrix.triple }}/debug/vrc-get*
conclude:
runs-on: ubuntu-latest

View file

@ -9,11 +9,11 @@ jobs:
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: actions/create-github-app-token@v3
- uses: actions/create-github-app-token@v2
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- uses: actions/labeler@v6
- uses: actions/labeler@v5
with:
repo-token: ${{ steps.app-token.outputs.token }}

View file

@ -17,7 +17,7 @@ jobs:
fmt:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- run: cargo fmt --all -- --check
@ -28,7 +28,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
submodules: recursive
@ -40,7 +40,7 @@ jobs:
run:
sudo apt update && sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
- uses: actions/cache@v5
- uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
@ -50,10 +50,10 @@ jobs:
target/
key: lints-${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- uses: auguwu/clippy-action@1.5.0
- uses: auguwu/clippy-action@1.4.0
with:
token: ${{secrets.GITHUB_TOKEN}}
check-args: --all --all-targets --all-features --exclude windows-installer-wrapper
check-args: --all --all-targets --all-features
args: -Dclippy::todo -Dwarnings
biome:
@ -63,7 +63,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: run Biome
working-directory: vrc-get-gui
run: npm run biome -- ci
@ -75,7 +75,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
- name: run Type Check
working-directory: vrc-get-gui
run: npm ci && npx tsc

View file

@ -12,13 +12,13 @@ jobs:
contents: read
discussions: write
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20.x'
- run: npm i --omit=dev
working-directory: .github/scripts
- uses: actions/github-script@v9
- uses: actions/github-script@v7
with:
script: |
const script = require('./.github/scripts/localization-updates.js')

View file

@ -1,642 +1,12 @@
name: Publish (GUI)
name: Apple Script Test
on:
workflow_dispatch:
inputs:
release_kind:
type: choice
description: The type of release.
default: prerelease
required: true
options:
- prerelease
- start-rc
- stable
dry-run:
type: boolean
description: Dry Run, If true, do not publish release to GitHub.
default: true
required: false
concurrency:
group: releasing
jobs:
pre-build:
name: Update version name
runs-on: ubuntu-latest
outputs:
gui-version: ${{ env.GUI_VERSION }}
prerelease: ${{ steps.update-version.outputs.prerelease }}
permissions:
contents: write
run-test:
runs-on: macos-latest
steps:
- uses: actions/checkout@v6
with:
submodules: recursive
- uses: anatawa12/something-releaser@v3
- uses: snow-actions/git-config-user@v1.0.0
- run: rustup update stable
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- name: Update Version Name
id: update-version
run: |
# set version name in properties file
case "$RELEASE_KIND_IN" in
"prerelease" )
get-version -t gui | version-next | set-version -t gui
gh-export-variable PRERELEASE true
gh-set-output prerelease true
;;
"start-rc" )
get-version -t gui | version-set-channel - rc 0 | set-version -t gui
gh-export-variable PRERELEASE true
gh-set-output prerelease true
;;
"stable" )
get-version -t gui | version-set-channel - stable | set-version -t gui
gh-export-variable PRERELEASE false
gh-set-output prerelease '' # empty string for false
;;
* )
echo "invalid release kind: $RELEASE_KIND_IN"
exit 255
;;
esac
GUI_VERSION="$(get-version -t gui)"
# update debian package
cp vrc-get-gui/bundle/debian/changelog vrc-get-gui/bundle/debian/changelog.bak
cat - vrc-get-gui/bundle/debian/changelog.bak <<CHANGELOG > vrc-get-gui/bundle/debian/changelog
alcom (${GUI_VERSION//-/\~}-1) stable;
* Upgraded version to ${GUI_VERSION}
-- anatawa12 <i@anatawa12.com> $(date -u +"%a, %d %b %Y %H:%M:%S +0000")
CHANGELOG
rm vrc-get-gui/bundle/debian/changelog.bak
# update rpm package
sed -i vrc-get-gui/bundle/alcom.spec -e "/^Version:/c\Version: ${GUI_VERSION//-/\~}"
case "$GITHUB_REF_NAME" in
master | master-* )
echo "head is master or master-*"
;;
* )
if [ "$DRY_RUN" = "true" ]; then
echo "head is not master, but DRY_RUN is true"
else
echo "head is not master, but DRY_RUN is false"
exit 255
fi
;;
esac
gh-export-variable GUI_VERSION "${GUI_VERSION}"
env:
RELEASE_KIND_IN: ${{ inputs.release_kind }}
DRY_RUN: ${{ inputs.dry-run }}
# region changelog
- name: Create Changelog
id: changelog
uses: anatawa12/sh-actions/changelog/prepare-release@master
with:
path: CHANGELOG-gui.md
version: ${{ env.GUI_VERSION }}
prerelease: ${{ env.PRERELEASE }}
tag-prefix: gui-v
prerelease-note-heading: |
Version ${{ env.GUI_VERSION }}
---
release-note-heading: |
Version ${{ env.GUI_VERSION }}
---
- name: Upload CHANGELOG.md
uses: actions/upload-artifact@v7
with:
name: CHANGELOG
path: CHANGELOG.md
- name: copy release note
run: cp "${{ steps.changelog.outputs.release-note }}" release-note.md
- name: Upload release note
uses: actions/upload-artifact@v7
with:
name: release-note-for-release
path: release-note.md
- name: remove temp release note file
run: rm release-note.md
# endregion changelog
- name: Commit
id: update
run: |-
# commit & tag
git commit -am "gui v$GUI_VERSION"
git branch releasing
git push -f -u origin releasing
build-rust:
name: Build rust
environment:
name: actions-code-signing
strategy:
fail-fast: false
matrix:
include:
# note: when you changed paths for tauri updater (which are files with .sig),
# remember keep in sync with build-updater-json
- name: x86_64-linux-appimage
triple: x86_64-unknown-linux-gnu
on: ubuntu-22.04
setup: |
sudo apt update && sudo apt install -y lld
ld.lld --version
sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf
rustflags: "-C link-arg=-fuse-ld=lld"
last-bundles: appimage,appimage-updater
updater-bundle: bundle/appimage/ALCOM_${GUI_VERSION}_x86_64.AppImage.tar.gz
dist-path: |
bundle/appimage/ALCOM_${GUI_VERSION}_x86_64.AppImage:alcom-${GUI_VERSION}-x86_64.AppImage
bundle/appimage/ALCOM_${GUI_VERSION}_x86_64.AppImage.tar.gz:alcom-${GUI_VERSION}-x86_64.AppImage.tar.gz
bundle/appimage/ALCOM_${GUI_VERSION}_x86_64.AppImage.tar.gz.sig:alcom-${GUI_VERSION}-x86_64.AppImage.tar.gz.sig
- name: x86_64-windows-all
triple: x86_64-pc-windows-msvc
on: windows-2022
last-bundles: setup-exe-zip,exe-updater
updater-bundle: bundle/setup/alcom-updater.exe
dist-path: |
ALCOM.exe:ALCOM-${GUI_VERSION}-x86_64.exe
bundle/setup/alcom-setup.exe:ALCOM-${GUI_VERSION}-x86_64-setup.exe
bundle/setup/alcom-setup.exe.zip:ALCOM-${GUI_VERSION}-x86_64-setup.exe.zip
bundle/setup/alcom-updater.exe:ALCOM-${GUI_VERSION}-x86_64-updater.exe
bundle/setup/alcom-updater.exe.sig:ALCOM-${GUI_VERSION}-x86_64-updater.exe.sig
- name: universal-macos-all
triple: universal-apple-darwin
on: macos-14
setup: |
rustup target add aarch64-apple-darwin
rustup target add x86_64-apple-darwin
last-bundles: dmg,app-updater
updater-bundle: bundle/macos/ALCOM.app.tar.gz
dist-path: |
bundle/dmg/ALCOM_${GUI_VERSION}_universal.dmg:ALCOM-${GUI_VERSION}-universal.dmg
bundle/macos/ALCOM.app.tar.gz:ALCOM-${GUI_VERSION}-universal.app.tar.gz
bundle/macos/ALCOM.app.tar.gz.sig:ALCOM-${GUI_VERSION}-universal.app.tar.gz.sig
name:
- x86_64-linux-appimage
#- aarch64-unknown-linux-musl
- x86_64-windows-all
#- aarch64-windows-all
- universal-macos-all
runs-on: ${{ matrix.on }}
env:
RUSTFLAGS: ${{ matrix.rustflags }}
needs: [ pre-build ]
steps:
- uses: actions/checkout@v6
with:
ref: 'releasing'
submodules: recursive
- run: rustup update stable
- name: Install cross-compilation tools
uses: taiki-e/setup-cross-toolchain-action@v1
if: ${{ matrix.triple != 'universal-apple-darwin' }}
with:
target: ${{ matrix.triple }}
- uses: Swatinem/rust-cache@v2
with:
cache-targets: false # for release build, do not cache build artifacts
key: release-gui # there are no elements about build result, so it's ok to share between all builds
- name: Setup
run: ${{ matrix.setup }}
- name: Build ALCOM binary
run: |
cargo xtask build-alcom --target ${{ matrix.triple }} --release ${{ matrix.alcom-build-options }}
- name: pre-sign Bundle ALCOM app (macOS)
if: ${{ contains(matrix.name, 'macos') }}
run: cargo xtask bundle-alcom --target ${{ matrix.triple }} --release --bundles app
- name: Sign ALCOM app (macOS)
if: ${{ contains(matrix.name, 'macos') }}
env:
APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }}
APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
APPLE_SIGNING_IDENTITY: ${{ secrets.APPLE_SIGNING_IDENTITY }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: cargo xtask sign-alcom-app --target ${{ matrix.triple }}
- name: Sign ALCOM exe (Windows)
if: ${{ contains(matrix.name, 'windows') }}
uses: ./.github/actions/sign-windows
with:
artifact-name: alcom-exe-unsigned
path: target/${{ matrix.triple }}/release/ALCOM.exe
signpath-api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
signing-policy-slug: ${{ case(inputs.dry-run, 'test-signing', 'release-signing') }}
version: ${{ needs.pre-build.outputs.gui-version }}
- name: Bundle Setup exe (Windows)
if: ${{ contains(matrix.name, 'windows') }}
run: cargo xtask bundle-alcom --target ${{ matrix.triple }} --release --bundles setup-exe
- name: Sign Setup exe (Windows)
if: ${{ contains(matrix.name, 'windows') }}
uses: ./.github/actions/sign-windows
with:
artifact-name: alcom-setup-exe-unsigned
path: target/${{ matrix.triple }}/release/bundle/setup/alcom-setup.exe
signpath-api-token: ${{ secrets.SIGNPATH_API_TOKEN }}
signing-policy-slug: ${{ case(inputs.dry-run, 'test-signing', 'release-signing') }}
version: ${{ needs.pre-build.outputs.gui-version }}
- name: Bundle ALCOM (${{ matrix.last-bundles }})
run: cargo xtask bundle-alcom --target ${{ matrix.triple }} --release --bundles ${{ matrix.last-bundles }}
- name: Sign updater artifacts (All Platforms)
shell: bash
if: ${{ matrix.updater-bundle }}
env:
GUI_VERSION: ${{ needs.pre-build.outputs.gui-version }}
UPDATER_BUNDLE: ${{ matrix.updater-bundle }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
run: |
UPDATER_BUNDLE="${UPDATER_BUNDLE//\$\{GUI_VERSION\}/$GUI_VERSION}"
cargo xtask sign-alcom-updater "target/${{ matrix.triple }}/release/${UPDATER_BUNDLE}"
- name: Move artifacts
if: ${{ !cancelled() }}
shell: bash
env:
GUI_VERSION: ${{ needs.pre-build.outputs.gui-version }}
DIST_PATH: ${{ matrix.dist-path }}
run: |-
mkdir artifacts
echo "$DIST_PATH" | while IFS=: read -r src dst; do
src="${src//\$\{GUI_VERSION\}/$GUI_VERSION}"
dst="${dst//\$\{GUI_VERSION\}/$GUI_VERSION}"
if [ -z "$dst" ]; then
continue
fi
printf "mv %s %s\n" "target/${{ matrix.triple }}/release/$src" "artifacts/$dst"
mv "target/${{ matrix.triple }}/release/$src" "artifacts/$dst"
done
- uses: actions/upload-artifact@v7
if: ${{ !cancelled() }}
with:
name: artifacts-${{ matrix.name }}
path: artifacts/*
build-rpm:
needs: [ pre-build ]
strategy:
fail-fast: false
matrix:
include:
- install_rust: false
- no_dist: false
- mock-env: fedora-40-x86_64
install_rust: true
no_dist: true
mock-env:
- fedora-40-x86_64
runs-on: ubuntu-latest
container:
image: 'fedora:latest'
options: --privileged
env:
MOCK_ENV: ${{ matrix.mock-env }}
RPMBUILD_OPTS: ${{ case(matrix.no_dist, '-D "dist %{nil}"', '') }} ${{ case(matrix.install_rust, '-D "install_rust 1"', '') }}
PKG_VERSION: ${{ needs.pre-build.outputs.gui-version }}
steps:
- name: Install CI dependencies
run: dnf install -y git tar curl
- uses: actions/checkout@v6
with:
ref: 'releasing'
submodules: recursive
# https://github.com/actions/checkout/issues/1169
- run: git config --system --add safe.directory $GITHUB_WORKSPACE
- name: install dependencies
run: dnf install -y mock rpmbuild
- name: prepare rpm build environment
run: mkdir -p ~/rpmbuild/{BUILD,RPMS,SOURCES,SPECS,SRPMS}
- name: build source rpm package
run: |
git archive --format=tar --prefix=vrc-get-gui-v$PKG_VERSION/ $(git write-tree) | gzip > ~/rpmbuild/SOURCES/gui-v$PKG_VERSION.tar.gz
eval "rpmbuild -bs vrc-get-gui/bundle/alcom.spec $RPMBUILD_OPTS"
- name: build rpm package
run: eval "mock -v -r '$(ls -1 /etc/mock{/eol,}/$MOCK_ENV.cfg 2>/dev/null)' --enable-network $RPMBUILD_OPTS rebuild ~/rpmbuild/SRPMS/alcom-${PKG_VERSION//-/\~}-1*.src.rpm"
- name: copy built binaries
run: |
mkdir -p artifacts
cp ~/rpmbuild/SRPMS/alcom-${PKG_VERSION//-/\~}-1*.src.rpm artifacts/
cp /var/lib/mock/$MOCK_ENV/result/alcom-${PKG_VERSION//-/\~}-1*.${MOCK_ENV##*-}.rpm artifacts/
- name: Upload built binary
uses: actions/upload-artifact@v7
with:
name: artifacts-rpm-${{ matrix.mock-env }}
path: artifacts/*
build-deb:
needs: [ pre-build ]
strategy:
fail-fast: false
matrix:
include:
- install_rust: false
- install_nodejs: false
- apt-components: main
- apt-with-updates: false
# Old distributions have older tools than we need. Download tools in build process
- pbuilder-distribution: jammy
install_rust: true
install_nodejs: true
# Debian uses mirror from debian-archive.trafficmanager.net which is managed by microsoft on azure
- pbuilder-distribution: jammy
mirror: http://archive.ubuntu.com/ubuntu/
apt-components: main universe
apt-with-updates: true
keyring: /usr/share/keyrings/ubuntu-archive-keyring.gpg
# We build on jammy since it's the distribution with a) libwebkit2gtk-4.1 >= 2.41 and 2) oldest libc version required.
# bookworm: libc6@2.36
# sid: libc6@2.39 as of 2026/06/14
# jammy: libc6@2.35
pbuilder-distribution:
- jammy # jammy is the oldest ubuntu release with libwebkit2gtk-4.1 >= 2.41 (but requires -updates and universe)
target-arch:
- amd64
runs-on: ubuntu-latest
env:
TARGET_ARCH: ${{ matrix.target-arch }}
PBUILDER_DISTRIBUTION: ${{ matrix.pbuilder-distribution }}
PBUILDER_MIRROR: ${{ matrix.mirror }}
PBUILDER_KEYRING: ${{ matrix.keyring }}
PBUILDER_COMPONENTS: ${{ matrix.apt-components }}
PBUILDER_OTHERMIRROR: ${{ case(matrix.apt-with-updates, format('deb {0} {1}-updates {2}', matrix.mirror, matrix.pbuilder-distribution, matrix.apt-components), '') }}
INSTALL_RUST: ${{ case(matrix.install_rust, '1', '0') }}
INSTALL_NODEJS: ${{ case(matrix.install_nodejs, '1', '0') }}
PKG_VERSION: ${{ needs.pre-build.outputs.gui-version }}
steps:
- uses: actions/checkout@v6
with:
ref: 'releasing'
path: vrc-get
submodules: recursive
- name: install dependencies
run: sudo apt update && sudo apt install -y pbuilder debian-archive-keyring debhelper-compat=13
- name: prepare deb build environment
working-directory: vrc-get
run: |
( mkdir debian && cd debian && ln -s ../vrc-get-gui/bundle/debian/* . )
sudo pbuilder create \
--architecture "$TARGET_ARCH" \
--keyring "$PBUILDER_KEYRING" \
--mirror "$PBUILDER_MIRROR" \
--distribution "$PBUILDER_DISTRIBUTION" \
--components "$PBUILDER_COMPONENTS" \
--othermirror "$PBUILDER_OTHERMIRROR"
- name: build source deb package
working-directory: vrc-get
run: |
git archive --format=tar HEAD | xz > ../alcom_${PKG_VERSION//-/\~}.orig.tar.xz
dpkg-buildpackage -d -S
- name: build deb package
working-directory: vrc-get
run: |
sudo --preserve-env=INSTALL_RUST,INSTALL_NODEJS pbuilder build --use-network yes ../alcom_${PKG_VERSION//-/\~}-1.dsc
- name: copy built binaries
run: |
mkdir -p artifacts
cp /var/cache/pbuilder/result/alcom_${PKG_VERSION//-/\~}-1_$TARGET_ARCH.deb artifacts/
cp /var/cache/pbuilder/result/alcom_${PKG_VERSION//-/\~}-1_$TARGET_ARCH.buildinfo artifacts/
cp /var/cache/pbuilder/result/alcom_${PKG_VERSION//-/\~}-1_$TARGET_ARCH.changes artifacts/
cp /var/cache/pbuilder/result/alcom_${PKG_VERSION//-/\~}-1.debian.tar.xz artifacts/
cp /var/cache/pbuilder/result/alcom_${PKG_VERSION//-/\~}-1.dsc artifacts/
ls artifacts
- name: Print information about built package
run: dpkg-deb -I artifacts/alcom_${PKG_VERSION//-/\~}-1_$TARGET_ARCH.deb
- name: Upload built binary
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v7
with:
name: artifacts-deb-${{ matrix.pbuilder-distribution }}-${{ matrix.target-arch }}
path: artifacts/*
build-updater-json:
runs-on: ubuntu-latest
needs: [ pre-build, build-rust ]
steps:
# use release
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- name: Download All Artifacts
uses: actions/download-artifact@v8
with:
path: assets
pattern: artifacts-*
merge-multiple: true
- name: Run updater-json
env:
GUI_VERSION: ${{ needs.pre-build.outputs.gui-version }}
run: cargo xtask alcom-updater-json --version "$GUI_VERSION" --assets assets updater.json
- name: Upload updater-json
uses: actions/upload-artifact@v7
with:
name: updater.json
path: |
updater.json
publish-to-github:
name: Publish to GitHub
if: ${{ !inputs.dry-run }}
environment:
name: actions-github-app
url: https://github.com/anatawa12/vrc-get/releases/gui-v${{ needs.pre-build.outputs.gui-version }}
permissions:
contents: write
runs-on: ubuntu-latest
needs: [ pre-build, build-rust, build-rpm, build-deb, build-updater-json ]
env:
GUI_VERSION: ${{ needs.pre-build.outputs.gui-version }}
steps:
- uses: actions/create-github-app-token@v3
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- uses: actions/checkout@v6
with:
ref: 'releasing'
fetch-depth: 2
submodules: recursive
token: ${{ steps.app-token.outputs.token }}
# tools
- uses: anatawa12/something-releaser@v3
- uses: snow-actions/git-config-user@v1.0.0
- uses: dtolnay/rust-toolchain@stable
- name: Download All Artifacts
uses: actions/download-artifact@v8
with:
path: assets
pattern: artifacts-*
merge-multiple: true
- name: Download changelog
# if: ${{ !needs.pre-build.outputs.prerelease }}
uses: actions/download-artifact@v8
with:
name: release-note-for-release
path: changelog
- name: Push tag
run: |-
# set tag and publish current version
git tag "gui-v$GUI_VERSION"
git push --tags
# create master and push
git switch -c master
git fetch origin master --depth=1
git log --all --graph
git push -u origin master
sleep 1
- name: create release
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |-
# latest = false because we need to have vrc-get cli in the latest release
# always generating notes file for now
# ${{ !needs.pre-build.outputs.prerelease && '--notes-file changelog/release-note.md' || '' }} \
gh release create \
${{ needs.pre-build.outputs.prerelease && '--prerelease' || '' }} \
--latest=false \
--notes-file changelog/release-note.md \
--verify-tag "gui-v$GUI_VERSION" \
assets/*
rm -rf outputs assets
- name: prepare next release & push
if: ${{ !needs.pre-build.outputs.prerelease }}
run: |
get-version -t gui | version-next | version-set-channel - beta 0 | set-version -t gui
GUI_NEXT="$(get-version -t gui | version-stable)"
git commit -am "chore: prepare for next version: gui $GUI_NEXT"
git push
cleanup:
name: Cleanup
if: ${{ !failure() && !cancelled() }}
permissions:
contents: write
runs-on: ubuntu-latest
needs:
- pre-build
- build-rust
- publish-to-github
steps:
- uses: actions/checkout@v6
with:
ref: 'releasing'
fetch-depth: 2
- name: remove releasing branch
run: git push --delete origin releasing
pull-request-to-website:
name: Create PullRequest to vrc-get.anatawa12.com for updater.json
if: ${{ !inputs.dry-run }}
environment:
name: actions-github-app
runs-on: ubuntu-latest
needs: [ pre-build, build-updater-json ]
env:
GUI_VERSION: ${{ needs.pre-build.outputs.gui-version }}
steps:
- uses: actions/create-github-app-token@v3
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
owner: vrc-get
repositories: vrc-get.anatawa12.com
- uses: actions/checkout@v6
with:
repository: 'vrc-get/vrc-get.anatawa12.com'
ref: 'master'
token: ${{ steps.app-token.outputs.token }}
- uses: snow-actions/git-config-user@v1.0.0
- name: Download updater.json
uses: actions/download-artifact@v8
with:
name: updater.json
path: .
- name: Move updater.json
env:
STABLE: ${{ !needs.pre-build.outputs.prerelease }}
run: |
mkdir -p public/api/gui
if $STABLE; then
rm public/api/gui/tauri-updater.json || true # remove old file if exists
mv updater.json public/api/gui/tauri-updater.json
fi
rm public/api/gui/tauri-updater-beta.json || true
mv updater.json public/api/gui/tauri-updater-beta.json
- name: Commit
run: |-
BRANCH_NAME="update-tauri-updater-json-v$GUI_VERSION"
git switch -c "$BRANCH_NAME"
git add public/api/gui/tauri-updater.json public/api/gui/tauri-updater-beta.json
git commit -m "chore: update tauri-updater.json to v$GUI_VERSION"
git push -u origin "$BRANCH_NAME"
- name: Create Pull Request
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
BRANCH_NAME="update-tauri-updater-json-v$GUI_VERSION"
gh pr create \
--title "chore: update tauri-updater.json to v$GUI_VERSION" \
--body "update tauri-updater.json to v$GUI_VERSION" \
--base master \
--head "$BRANCH_NAME" \
--assignee anatawa12 \
- uses: actions/checkout@v4
- name: Run test AppleScript
run: osascript test.applescript "$(pwd)"

View file

@ -12,14 +12,6 @@ on:
- prerelease
- start-rc
- stable
dry-run:
type: boolean
description: Dry Run, If true, do not publish release to GitHub.
default: true
required: false
concurrency:
group: releasing
jobs:
pre-build:
@ -32,7 +24,7 @@ jobs:
permissions:
contents: write
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
submodules: recursive
- uses: anatawa12/something-releaser@v3
@ -75,12 +67,8 @@ jobs:
echo "head is master, master-*, or hotfix-*"
;;
* )
if [ "$DRY_RUN" = "true" ]; then
echo "head is not master, but DRY_RUN is true"
else
echo "head is not master, but DRY_RUN is false"
exit 255
fi
echo "invalid release kind: $RELEASE_KIND_IN is not allowd for $GITHUB_REF_NAME"
exit 255
;;
esac
@ -88,7 +76,6 @@ jobs:
gh-export-variable VPM_VERSION "$(get-version -t vpm)"
env:
RELEASE_KIND_IN: ${{ github.event.inputs.release_kind }}
DRY_RUN: ${{ inputs.dry-run }}
# region changelog
- name: Create Changelog
@ -107,7 +94,7 @@ jobs:
---
- name: Upload CHANGELOG.md
if: ${{ !steps.update-version.outputs.prerelease }}
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: CHANGELOG
path: CHANGELOG.md
@ -116,7 +103,7 @@ jobs:
run: cp "${{ steps.changelog.outputs.release-note }}" release-note.md
- name: Upload release note
if: ${{ !steps.update-version.outputs.prerelease }}
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: release-note-for-release
path: release-note.md
@ -177,7 +164,7 @@ jobs:
needs: [ pre-build ]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
ref: 'releasing'
submodules: recursive
@ -199,13 +186,12 @@ jobs:
run: cargo build --target ${{ matrix.triple }} --release --verbose
- name: Check binary is statically linked
shell: bash
env:
RUSTFLAGS: ''
run: |
cargo xtask check-static-link target/${{ matrix.triple }}/release/vrc-get${WINDIR:+.exe}
# https://github.com/taiki-e/setup-cross-toolchain-action/issues/18
unset CARGO_BUILD_TARGET
cargo run -p build-check-static-link target/${{ matrix.triple }}/release/vrc-get*
- name: Move artifacts
if: ${{ !cancelled() }}
shell: bash
run: |-
mkdir artifacts
@ -215,22 +201,20 @@ jobs:
done
popd
- uses: actions/upload-artifact@v7
if: ${{ !cancelled() }}
- uses: actions/upload-artifact@v4
with:
name: artifacts-${{ matrix.triple }}
path: artifacts/*
publish-crates-io:
name: Publish to crates.io
if: ${{ !inputs.dry-run }}
environment:
name: crates.io
url: https://crates.io/crates/vrc-get
runs-on: ubuntu-latest
needs: [ pre-build, build-rust ]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
ref: 'releasing'
fetch-depth: 1
@ -248,7 +232,6 @@ jobs:
publish-to-github:
name: Publish to GitHub
if: ${{ !inputs.dry-run }}
environment:
name: actions-github-app
url: https://github.com/anatawa12/vrc-get/releases/v${{ needs.pre-build.outputs.cli-version }}
@ -260,12 +243,12 @@ jobs:
CLI_VERSION: ${{ needs.pre-build.outputs.cli-version }}
VPM_VERSION: ${{ needs.pre-build.outputs.vpm-version }}
steps:
- uses: actions/create-github-app-token@v3
- uses: actions/create-github-app-token@v2
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
ref: 'releasing'
fetch-depth: 2
@ -278,7 +261,7 @@ jobs:
- uses: dtolnay/rust-toolchain@stable
- name: Download All Artifacts
uses: actions/download-artifact@v8
uses: actions/download-artifact@v4
with:
path: assets
pattern: artifacts-*
@ -286,7 +269,7 @@ jobs:
- name: Download changelog
if: ${{ !needs.pre-build.outputs.prerelease }}
uses: actions/download-artifact@v8
uses: actions/download-artifact@v4
with:
name: release-note-for-release
path: changelog
@ -343,7 +326,7 @@ jobs:
CLI_VERSION: ${{ needs.pre-build.outputs.cli-version }}
VPM_VERSION: ${{ needs.pre-build.outputs.vpm-version }}
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v4
with:
ref: 'releasing'
fetch-depth: 2
@ -353,7 +336,7 @@ jobs:
publish-to-homebrew:
name: Publish to homebrew
# vrc-get is on autobump list https://github.com/Homebrew/homebrew-core/blame/master/.github/autobump.txt
if: false # ${{ !inputs.dry-run && !needs.pre-build.outputs.prerelease }}
if: false # ${{ !needs.pre-build.outputs.prerelease }}
environment:
name: homebrew-core
url: https://github.com/homebrew/homebrew-core
@ -367,7 +350,7 @@ jobs:
publish-to-winget:
name: Publish to winget
if: ${{ !inputs.dry-run && !needs.pre-build.outputs.prerelease }}
if: ${{ !needs.pre-build.outputs.prerelease }}
needs: [ pre-build, publish-to-github ]
uses: vrc-get/vrc-get/.github/workflows/publish-cli-winget.yml@master

View file

@ -8,7 +8,6 @@ The format is based on [Keep a Changelog].
## [Unreleased]
### Added
- Implement project sorting by creation date `#2941`
### Changed
@ -17,127 +16,10 @@ The format is based on [Keep a Changelog].
### Removed
### Fixed
- Unity can be duplicated `#2321`
### Security
## [1.1.6] - 2026-06-02
### Added
- The package list can show hidden packages. [`#2731`](https://github.com/vrc-get/vrc-get/pull/2731)
- Build-time option to disable auto updater [`#2759`](https://github.com/vrc-get/vrc-get/pull/2759)
- Please read README for new build instruction.
- User repositories can now be reordered by drag and drop [`#2935`](https://github.com/vrc-get/vrc-get/pull/2935)
### Changed
- The "Clear Selection" button in the package management screen is now red (destructive style) to distinguish it from the "Install Selected" button [`#2803`](https://github.com/vrc-get/vrc-get/pull/2803)
- File filled with '\0' or whitespace will be treated as empty file [`#2710`](https://github.com/vrc-get/vrc-get/pull/2710)
- This should prevent `syntax error loading settings.json: expected value at line 1 column 1` if settings.json is broken
- We also added a backup file to recover from settings.json corruption [`#2933`](https://github.com/vrc-get/vrc-get/pull/2933)
- Completely changed how do we build ALCOM and how do we self-update ALCOM [`#2759`](https://github.com/vrc-get/vrc-get/pull/2759) [`#2828`](https://github.com/vrc-get/vrc-get/pull/2828) [`#2881`](https://github.com/vrc-get/vrc-get/pull/2881) [`#2882`](https://github.com/vrc-get/vrc-get/pull/2882) [`#2885`](https://github.com/vrc-get/vrc-get/pull/2885)
- This fixes few problems relates to auto update
- Please read README for new build instruction.
- Improved backup speed by parallelizing the process [`#2746`](https://github.com/vrc-get/vrc-get/pull/2746)
- Along with this change, the default compression level has been changed to `zip-fast`
- We added dialog on enabling "Show Prerelease Packages" [`#2795`](https://github.com/vrc-get/vrc-get/pull/2795)
- I hope this prevents users unexpectedly adding prerelease packages
- Path for unitypackage on Template Editor now can be reselected [`#2635`](https://github.com/vrc-get/vrc-get/pull/2635)
- ALCOM now refuses launching project if project is on noexec mount points [`#2814`](https://github.com/vrc-get/vrc-get/pull/2814)
- This would cause problems with several native plugins
- Already-added packages are now excluded from the package name suggestions in the Template Editor [`#2828`](https://github.com/vrc-get/vrc-get/pull/2828)
- Extended some timeouts to 1 minute [`#2826`](https://github.com/vrc-get/vrc-get/pull/2826)
- Prevents timeouts in slow DNS environments
- Improved robustness for package installation errors [`#2844`](https://github.com/vrc-get/vrc-get/pull/2844)
- It is now unlikely that vrc-get will leave the project directory corrupted if an I/O error occurs while installing a package
- Backslashes in path in zip file are now treated as path separator on unix [`#2845`](https://github.com/vrc-get/vrc-get/pull/2845)
- This fixes problem with Gesture Manager 3.9.7
- Empty string for `documentationUrl` and `changelogUrl` are now allowed and ignored [`#2930`](https://github.com/vrc-get/vrc-get/pull/2930)
- They are formerly rejected as invalid url
### Fixed
- Fixed an issue where the progress bar flickered and did not display correct progress in environments using WebKit as the renderer. [`#2641`](https://github.com/vrc-get/vrc-get/pull/2641)
- Fails to import UnityPackages with files in `Packages` directory [`#2679`](https://github.com/vrc-get/vrc-get/pull/2679)
- null as vpmDependencies value is not allowed [`#2709`](https://github.com/vrc-get/vrc-get/pull/2709)
- It's not recommended, but we allow null for `vpmDependencies` as a alias of `{}`
- ALCOM cannot detect per-user flatpak installation of unity hub [`#2812`](https://github.com/vrc-get/vrc-get/pull/2812)
- Unabled to import some untypackages [`#2821`](https://github.com/vrc-get/vrc-get/pull/2821)
- It's hard to say but some older unitypackages ware unsupported.
- Panic when resolving projects where dependency packages depend on newer versions of locked packages [`#2822`](https://github.com/vrc-get/vrc-get/pull/2822)
- Missing glibc and libgcc_s dependency notation in .deb / .rpm distributon [`#2828`](https://github.com/vrc-get/vrc-get/pull/2828)
- Unclear error message for invalid version name or version range [`#2842`](https://github.com/vrc-get/vrc-get/pull/2842)
- Default file names in save dialogs now include the appropriate file extension [`#2846`](https://github.com/vrc-get/vrc-get/pull/2846)
- Template export now defaults to `{template name}.alcomtemplate`
- Repository list export now defaults to `repositories.txt`
- Uninformative `[object Object]` appearing as an error message [`#2848`](https://github.com/vrc-get/vrc-get/pull/2848)
- New Unity Hub loading method may not load manually added Unity Editors [`#2850`](https://github.com/vrc-get/vrc-get/pull/2850)
- New Unity Hub loading method does load unity hub configuration on Linux [`#2850`](https://github.com/vrc-get/vrc-get/pull/2850)
- Too many open files when copying project `#2867
- Added workaround for VRCDefaultWorldScene generation issue in SDK 3.10.2 or later [`#2916`](https://github.com/vrc-get/vrc-get/pull/2916)
- See [this][default-scene-canny] canny for bug in VRCSDK and issue [#2913][issue-2913] for our decision.
### Security
- Package hash checks are now enforced when installing packages [`#2849`](https://github.com/vrc-get/vrc-get/pull/2849)
- It has been about two years since the error message for package hash mismatches was introduced.
- It is now enforced for security.
[default-scene-canny]: https://feedback.vrchat.com/sdk-bug-reports/p/3102-3103-vrcscenetemplateinitializer-does-not-create-sample-scene-if-udon-prepr
[issue-2913]: https://github.com/vrc-get/vrc-get/issues/2913
## [1.1.5] - 2025-11-16
- Fix package version selector dropdown exceeding window height [`#2589`](https://github.com/vrc-get/vrc-get/pull/2589)
- The dropdown list now has a maximum height of 50% of the viewport or 24rem, whichever is smaller
- This prevents the version selector from overflowing the window on small screens
- Fix muted-foreground color [`#2516`](https://github.com/vrc-get/vrc-get/pull/2516) [`#2517`](https://github.com/vrc-get/vrc-get/pull/2517)
- Remove `DialogDescription` not in `DialogHeader` to fix text color
- Fix 'Detected Loop' panic with valid database file [`#2607`](https://github.com/vrc-get/vrc-get/pull/2607)
## [1.1.4] - 2025-09-02
### Added
- Add compact gui option [`#2436`](https://github.com/vrc-get/vrc-get/pull/2436) [`#2450`](https://github.com/vrc-get/vrc-get/pull/2450) [`#2470`](https://github.com/vrc-get/vrc-get/pull/2470)
### Changed
- Improved saving interacting with setting files [`#2485`](https://github.com/vrc-get/vrc-get/pull/2485)
- This should reduce "EOF while parsing a value at line 1 column 0" error on launch.
- This should reduce losing settings after crashing ALCOM or PC.
### Fixed
- Specifying a single unityversion doesn't work properly in alcomtemplate [`#2452`](https://github.com/vrc-get/vrc-get/pull/2452)
- For example, if you'd like to specify `2022.3.22f1`, you need to set `2022.3.22`, not `2022.3.22f1`
- You can now see correct validation and suggestions for this.
- Home/End and Up/Down keys now consistently control cursor position in autocomplete fields [`#2466`](https://github.com/vrc-get/vrc-get/pull/2466)
- Home/End keys now always move the text cursor regardless of autocomplete state
- Up/Down keys move the text cursor when suggestions are not visible, and navigate suggestions when they are visible
- Previously, these keys would sometimes be captured for suggestion navigation when autocomplete was open
## [1.1.3] - 2025-07-28
### Added
- Add support for `keywords` UPM manifest field [`#2375`](https://github.com/vrc-get/vrc-get/pull/2375)
- You now can specifiy search keywords for package with `keywords` UPM manifest field
- Favorites for templates [`#2376`](https://github.com/vrc-get/vrc-get/pull/2376)
- It's much easier to select project templates you likely to use.
### Changed
- Improved the Template Editor with AutoComplete [`#2371`](https://github.com/vrc-get/vrc-get/pull/2371)
- You no longer need to remember the package name (id) and version associated with the package.
- You now can search package by display name, name (id), aliases to enter package name, and ALCOM shows common version range for you.
- Updated project settings of templates to include Item layer [`#2373`](https://github.com/vrc-get/vrc-get/pull/2373)
- You should no longer need to update layers and collision matrix before uploading world
- Improved behavior about `settings.json` to `vcc.litedb` migration [`#2327`](https://github.com/vrc-get/vrc-get/pull/2327)
- See [`vrchat-community/creator-companion#492`](https://github.com/vrchat-community/creator-companion/issues/492) and the PR for details
- Last used template is now preserved [`#2376`](https://github.com/vrc-get/vrc-get/pull/2376)
- When you generally create project with custom template, you no longer need to change template every time.
### Fixed
- Packages are not deselected after installing packages [`#2372`](https://github.com/vrc-get/vrc-get/pull/2372)
## [1.1.2] - 2025-06-30
### Fixed
- Fixed `a - b` version range is not correctly serialized on the `vpm-manifest.json`
- Frontend error on package list update [`#2341`](https://github.com/vrc-get/vrc-get/pull/2341)
## [1.1.1] - 2025-06-21
### Fixed
- Unity can be duplicated [`#2321`](https://github.com/vrc-get/vrc-get/pull/2321)
- Crash on creating a new project on Windows [`#2326`](https://github.com/vrc-get/vrc-get/pull/2326)
## [1.1.0] - 2025-06-19
### Added
- Support for Projects with Unity 2018 or older [`#2106`](https://github.com/vrc-get/vrc-get/pull/2106)
@ -703,13 +585,7 @@ Release pipeline fixes
- Apple code signing [`#422`](https://github.com/anatawa12/vrc-get/pull/422)
- Migrate vpm 2019 project to 2022 [`#435`](https://github.com/anatawa12/vrc-get/pull/435)
[Unreleased]: https://github.com/vrc-get/vrc-get/compare/gui-v1.1.6...HEAD
[1.1.6]: https://github.com/vrc-get/vrc-get/compare/gui-v1.1.5...gui-v1.1.6
[1.1.5]: https://github.com/vrc-get/vrc-get/compare/gui-v1.1.4...gui-v1.1.5
[1.1.4]: https://github.com/vrc-get/vrc-get/compare/gui-v1.1.3...gui-v1.1.4
[1.1.3]: https://github.com/vrc-get/vrc-get/compare/gui-v1.1.2...gui-v1.1.3
[1.1.2]: https://github.com/vrc-get/vrc-get/compare/gui-v1.1.1...gui-v1.1.2
[1.1.1]: https://github.com/vrc-get/vrc-get/compare/gui-v1.1.0...gui-v1.1.1
[Unreleased]: https://github.com/vrc-get/vrc-get/compare/gui-v1.1.0...HEAD
[1.1.0]: https://github.com/vrc-get/vrc-get/compare/gui-v1.0.1...gui-v1.1.0
[1.0.1]: https://github.com/vrc-get/vrc-get/compare/gui-v1.0.0...gui-v1.0.1
[1.0.0]: https://github.com/vrc-get/vrc-get/compare/gui-v0.1.17...gui-v1.0.0

View file

@ -10,52 +10,28 @@ The format is based on [Keep a Changelog].
### Added
### Changed
- Improved saving interacting with setting files `#2485` `#2710`
- This should reduce "EOF while parsing a value at line 1 column 0" error on launch.
- This should reduce losing settings after crashing ALCOM or PC.
- null as vpmDependencies value is not allowed `#2709`
- It's not recommended, but we allow null for `vpmDependencies` as a alias of `{}`
- Improved robustness for package installation errors `#2844`
- It is now unlikely that vrc-get will leave the project directory corrupted if an I/O error occurs while installing a package
- Backslashes in path in zip file are now treated as path separator on unix `#2845`
- This fixes problem with Gesture Manager 3.9.7
- Changed how we read VCC's project information `#1997`
- Along with this, building this project no longer needs dotnet SDK to build.
- Migrated the project to Rust 2024 `#1956`
- This is internal changes should not cause behavior changes
- This would require Rust 1.85 for building this project
- Removed `cargo-about` from build-time dependency `#1961`
- This is internal changes should not cause behavior changes
- I listed here since this may need update on package metadata of some package managers
- The method to retrieve the list of Unity from Unity Hub `#1808` `#1971`
- You now can select multiple folders at once to adding project `#2018`
- I didn't know official VCC had such a feature. Sorry for lack of feature!
- The requirements for unity project `#2106`
- Since this version, `Projectsettings/ProjectVersion.txt` is required.
### Deprecated
### Removed
### Fixed
- Fix 'Detected Loop' panic with valid database file `#2607`
- Panic when resolving projects where dependency packages depend on newer versions of locked packages `#2822`
- Warning for backup/project path in AppData folder not shown when path is in Roaming or LocalLow [`#2827`](https://github.com/vrc-get/vrc-get/pull/2827)
- Unclear error message for invalid version name or version range `#2842`
- Empty string for `documentationUrl` and `changelogUrl` are now allowed and ignored `#2930`
- They are formerly rejected as invalid url
- Uninstall package is not reverted successfully if removing package is prevented by `ERROR_SHARING_VIOLATION` `#2209`
### Security
- Package hash checks are now enforced when installing packages `#2849`
- It has been about two years since the error message for package hash mismatches was introduced.
- It is now enforced for security.
## [1.9.1] - 2025-07-28
### Changed
- Changed how we read VCC's project information [`#1997`](https://github.com/vrc-get/vrc-get/pull/1997)
- Along with this, building this project no longer needs dotnet SDK to build.
- Migrated the project to Rust 2024 [`#1956`](https://github.com/vrc-get/vrc-get/pull/1956)
- This is internal changes should not cause behavior changes
- This would require Rust 1.85 for building this project
- Removed `cargo-about` from build-time dependency [`#1961`](https://github.com/vrc-get/vrc-get/pull/1961)
- This is internal changes should not cause behavior changes
- I listed here since this may need update on package metadata of some package managers
- The method to retrieve the list of Unity from Unity Hub [`#1808`](https://github.com/vrc-get/vrc-get/pull/1808) [`#1971`](https://github.com/vrc-get/vrc-get/pull/1971)
- You now can select multiple folders at once to adding project [`#2018`](https://github.com/vrc-get/vrc-get/pull/2018)
- I didn't know official VCC had such a feature. Sorry for lack of feature!
- The requirements for unity project [`#2106`](https://github.com/vrc-get/vrc-get/pull/2106)
- Since this version, `Projectsettings/ProjectVersion.txt` is required.
### Fixed
- Uninstall package is not reverted successfully if removing package is prevented by `ERROR_SHARING_VIOLATION` [`#2209`](https://github.com/vrc-get/vrc-get/pull/2209)
- Fixed `a - b` version range is not correctly serialized on the `vpm-manifest.json`
## [1.9.0] - 2025-01-01
### Added
@ -506,8 +482,7 @@ The format is based on [Keep a Changelog].
## [0.1.0] - 2023-01-25
Initial Release
[Unreleased]: https://github.com/vrc-get/vrc-get/compare/v1.9.1...HEAD
[1.9.1]: https://github.com/vrc-get/vrc-get/compare/v1.9.0...v1.9.1
[Unreleased]: https://github.com/vrc-get/vrc-get/compare/v1.9.0...HEAD
[1.9.0]: https://github.com/vrc-get/vrc-get/compare/v1.8.2...v1.9.0
[1.8.2]: https://github.com/vrc-get/vrc-get/compare/v1.8.1...v1.8.2
[1.8.1]: https://github.com/vrc-get/vrc-get/compare/v1.8.0...v1.8.1

3593
Cargo.lock generated

File diff suppressed because it is too large Load diff

View file

@ -2,10 +2,10 @@
resolver = "2"
members = [
"xtask",
"build-check-static-link",
"build-updater-json",
"vrc-get",
"vrc-get-gui",
"vrc-get-gui/windows-installer-wrapper",
"vrc-get-vpm",
]
@ -24,10 +24,3 @@ authors = ["anatawa12 <anatawa12@icloud.com>"]
homepage = "https://github.com/anatawa12/vrc-get#readme"
repository = "https://github.com/anatawa12/vrc-get"
readme = "README.md"
[profile.xtask]
inherits = "dev"
incremental = false
debug = 1
opt-level = 0
lto = "off"

View file

@ -0,0 +1 @@
/*

View file

@ -0,0 +1,23 @@
[package]
name = "build-check-static-link"
version = "0.1.0"
edition.workspace = true
license.workspace = true
authors.workspace = true
homepage.workspace = true
repository.workspace = true
readme.workspace = true
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
[dependencies.object]
version = "0.37"
default-features = false
features = [
"read_core",
"macho",
"pe",
"elf",
]

View file

@ -0,0 +1,11 @@
This crate is to ensure built binary is statically linked or dynamically linked to the system libraries.
this crate use `object` crate to read the binary file.
## Usage
```
cargo run -p build-check-static-link <path/to/binary>
```
exits with zero if statically linked or linked with allowed dynamic libraries, otherwise exits with non-zero.

View file

@ -0,0 +1,132 @@
use object::{Endian, Endianness, FileKind, Object};
use std::fs;
use std::process::exit;
fn main() {
let mut args = std::env::args();
let _ = args.next();
let mut success = true;
for arg in args {
if arg.ends_with(".d") {
println!("skipping .d file: {}", arg);
continue;
}
let binary = std::path::Path::new(&arg);
let binary = fs::read(binary).unwrap();
success |= match FileKind::parse(binary.as_slice()).expect("detecting type") {
FileKind::MachO64 => process_mach_64::<Endianness>(&binary),
FileKind::Pe64 => process_pe_64(&binary),
FileKind::Elf64 => process_elf_64::<Endianness>(&binary),
unknown => panic!("unknown file type: {:?}", unknown),
};
}
if success { exit(0) } else { exit(1) }
}
fn process_mach_64<E: Endian>(binary: &[u8]) -> bool {
use object::macho::*;
use object::read::macho::*;
let mut success = true;
let parsed = MachHeader64::<E>::parse(binary, 0).expect("failed to parse binary");
let endian = parsed.endian().unwrap();
let mut commands = parsed
.load_commands(endian, binary, 0)
.expect("parsing binary");
while let Some(command) = commands.next().expect("reading binary") {
if let Some(dylib) = command.dylib().unwrap() {
let dylib = command.string(endian, dylib.dylib.name).unwrap();
match dylib {
| b"/System/Library/Frameworks/Security.framework/Versions/A/Security"
| b"/System/Library/Frameworks/SystemConfiguration.framework/Versions/A/SystemConfiguration"
| b"/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation"
| b"/System/Library/Frameworks/Foundation.framework/Versions/C/Foundation"
| b"/usr/lib/libobjc.A.dylib"
| b"/usr/lib/libiconv.2.dylib"
| b"/usr/lib/libSystem.B.dylib"
=> {
// known system library
println!("system dylib: {}", std::str::from_utf8(dylib).unwrap());
}
unknown => {
println!("ERROR: unknown dylib: {:?}", std::str::from_utf8(unknown).unwrap_or("unable to parse with utf8"));
success = false;
},
}
} else if command.cmd() == LC_LOAD_DYLINKER {
let data: &DylinkerCommand<E> = command.data().expect("parse LC_LOAD_DYLINKER");
if command.string(endian, data.name).unwrap() != b"/usr/lib/dyld" {
println!("ERROR: dylinker is not /usr/lib/dyld");
success = false;
} else {
println!("dylinker: /usr/lib/dyld");
}
}
}
success
}
fn process_pe_64(binary: &[u8]) -> bool {
use object::LittleEndian as LE;
use object::read::pe::*;
let mut success = true;
let parsed = PeFile64::parse(binary).expect("failed to parse binary");
let table = parsed.import_table().unwrap().unwrap();
let mut iter = table.descriptors().unwrap();
while let Some(x) = iter.next().unwrap() {
let dll = table.name(x.name.get(LE)).unwrap();
match dll.to_ascii_lowercase().as_slice() {
| b"advapi32.dll"
| b"kernel32.dll"
| b"bcrypt.dll" // TODO: check if this is a system library
| b"ntdll.dll"
| b"shell32.dll"
| b"ole32.dll"
| b"ws2_32.dll"
| b"crypt32.dll"
=> {
println!("system dll: {}", std::str::from_utf8(dll).unwrap());
// known system library
}
unknown => {
println!("ERROR: unknown dll: {:?}", std::str::from_utf8(unknown).unwrap_or("unable to parse with utf8"));
success = false;
},
}
}
success
}
fn process_elf_64<E: Endian>(binary: &[u8]) -> bool {
use object::elf::*;
use object::read::elf::*;
let mut success = true;
let parsed = ElfFile64::<E>::parse(binary).expect("failed to parse binary");
for x in parsed.imports().unwrap() {
println!(
"dynamic importing symbol: {}",
std::str::from_utf8(x.name()).unwrap()
);
success = false;
}
for segment in parsed.elf_program_headers() {
if segment.p_type.get(parsed.endian()) == PT_INTERP {
let data = segment.data(parsed.endian(), parsed.data()).unwrap();
println!("interpreter: {:?}", std::str::from_utf8(data).unwrap());
success = false;
}
}
success
}

View file

@ -0,0 +1 @@
/*

View file

@ -0,0 +1,17 @@
[package]
name = "build-updater-json"
version = "0.1.0"
edition.workspace = true
license.workspace = true
authors.workspace = true
homepage.workspace = true
repository.workspace = true
readme.workspace = true
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
chrono = { version = "0.4", default-features = false, features = ["now", "serde"] }
indexmap = { version = "2", features = ["serde"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"

View file

@ -0,0 +1,85 @@
// see https://tauri.app/v1/guides/distribution/updater/ for json format
use chrono::{Timelike, Utc};
use indexmap::IndexMap;
use serde::Serialize;
use std::path::Path;
#[derive(Serialize)]
struct UpdaterJson {
version: String,
notes: String,
pub_date: chrono::DateTime<Utc>,
platforms: IndexMap<String, Platform>,
}
#[derive(Serialize)]
struct Platform {
signature: String,
url: String,
}
fn main() {
// consts
const DOWNLOAD_URL_BASE: &str =
"https://github.com/vrc-get/vrc-get/releases/download/gui-v{version}";
let platform_file_name = [
("darwin-x86_64", "ALCOM-{version}-universal.app.tar.gz"),
("darwin-aarch64", "ALCOM-{version}-universal.app.tar.gz"),
("linux-x86_64", "alcom-{version}-x86_64.AppImage.tar.gz"),
//("linux-aarch64", "alcom-{version}-aarch64.AppImage.tar.gz"),
("windows-x86_64", "ALCOM-{version}-x86_64-setup.nsis.zip"),
//("windows-aarch64", "ALCOM-{version}-aarch64-setup.nsis.zip"),
]
.into_iter()
.collect::<IndexMap<_, _>>();
let version = std::env::var("GUI_VERSION").expect("GUI_VERSION not set");
let base_url = DOWNLOAD_URL_BASE.replace("{version}", &version);
// create platforms info
let mut platforms = IndexMap::new();
for (platform, file_name) in platform_file_name {
let file_name = file_name.replace("{version}", &version);
std::fs::metadata(format!("assets/{file_name}"))
.unwrap_or_else(|e| panic!("{}: {}", file_name, e));
let signature = std::fs::read_to_string(format!("assets/{file_name}.sig"))
.unwrap_or_else(|e| panic!("{}.sig: {}", file_name, e));
let url = format!("{}/{}", base_url, file_name);
platforms.insert(platform.to_string(), Platform { signature, url });
}
let is_beta = version.contains('-');
let notes = if is_beta {
// https://github.com/vrc-get/vrc-get/blob/master/CHANGELOG-gui.md#unreleased
"Please read changelog at https://github.com/vrc-get/vrc-get/blob/master/CHANGELOG-gui.md#unreleased".into()
} else {
// https://github.com/vrc-get/vrc-get/blob/master/CHANGELOG-gui.md#101---2025-02-05
let version = version.replace('.', "");
let date = Utc::now().format("%Y-%m-%d").to_string();
format!(
"Please read changelog at https://github.com/vrc-get/vrc-get/blob/master/CHANGELOG-gui.md#{version}---{date}"
)
};
let updater = UpdaterJson {
version,
notes,
pub_date: Utc::now().with_nanosecond(0).unwrap(),
platforms,
};
if !is_beta {
write_json("updater.json", &updater);
}
write_json("updater-beta.json", &updater);
}
fn write_json(path: impl AsRef<Path>, json: impl Serialize) {
let json = serde_json::to_string_pretty(&json).unwrap();
std::fs::write(path, json).expect("write updater.json");
}

10
test.applescript Normal file
View file

@ -0,0 +1,10 @@
#!/usr/bin/env osascript
on run (folderName)
tell application "Finder"
tell folder folderName
log "opening folder " & folderName
open
end tell
end tell
end run

View file

@ -1 +0,0 @@
ignore-scripts=true

View file

@ -1,6 +1,6 @@
[package]
name = "vrc-get-gui"
version = "1.1.7-beta.0"
version = "1.1.1-rc.0"
description = "A fast open-source alternative of VRChat Creator Companion"
homepage.workspace = true
@ -22,17 +22,17 @@ tauri-build = { version = "2", features = [ "config-toml" ] }
serde_json = "1"
serde = { version = "1", features = ["derive"] }
serde_with = { version = "3", features = ["base64"] }
tauri = { version = "=2.11.2", features = [ "config-toml" ] } # = for sync version between npm and cargo
tauri = { version = "2", features = [ "config-toml" ] }
vrc-get-vpm = { path = "../vrc-get-vpm", features = ["experimental-project-management", "experimental-unity-management"] }
reqwest = { version = "0.13", features = ["gzip", "brotli", "json"] }
specta = { version = "2.0.0-rc.24", features = [ "chrono", "url", "indexmap" ] }
tauri-specta = { version = "2.0.0-rc.24", features = ["typescript"] }
specta-typescript = "0.0.11"
reqwest = { version = "0.12", features = ["gzip", "brotli"] }
specta = { version = "2.0.0-rc.20", features = [ "chrono", "url", "indexmap" ] }
tauri-specta = { version = "2.0.0-rc.20", features = ["typescript"] }
specta-typescript = "0.0.7"
open = "5"
arc-swap = "1"
log = { version = "0.4", features = [ "std", "kv" ] }
chrono = { version = "0.4", features = [ "serde" ] }
ringbuffer = "0.16"
ringbuffer = "0.15"
tokio = { version = "1", features = ["process"] }
tokio-util = "0.7"
fs_extra = "1"
@ -42,17 +42,12 @@ tar = "0.4"
flate2 = "1"
uuid = { version = "1", features = ["v4"] }
trash = "5"
async_zip = { version = "0.0.18", features = ["tokio", "deflate"] }
async_zip = { version = "0.0.17", features = ["deflate", "tokio"] }
async-compression = { version = "0.4", features = ["gzip", "futures-io"] }
async-stream = "0.3"
tauri-plugin-single-instance = "2"
tauri-plugin-updater = "2"
tauri-plugin-dialog = "2"
minisign-verify = "0.2"
base64 = "0.22"
semver = "1"
tempfile = "3"
sha2 = "0.11"
hex = "0.4"
sys-locale = "0.3"
log-panics = { version = "2", features = ["with-backtrace"] }
url = "2"
@ -61,12 +56,11 @@ yoke = { version = "0.8", features = ["derive"] }
atomicbox = "0.4"
stable_deref_trait = "1"
itertools = "0.14"
sysinfo = "0.39.3"
[target.'cfg(windows)'.dependencies]
windows = { version = "0.62", features = ["Win32_Storage_FileSystem", "Win32_System_IO", "Win32_NetworkManagement_IpHelper", "Wdk_System_SystemServices", "Win32_System_SystemInformation", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] }
winreg = "0.56"
wmi = "0.18"
windows = { version = "0.61", features = ["Win32_Storage_FileSystem", "Win32_System_IO", "Win32_NetworkManagement_IpHelper", "Wdk_System_SystemServices", "Win32_System_SystemInformation"] }
winreg = "0.55"
wmi = "0.17"
[target.'cfg(target_os = "macos")'.dependencies]
plist = { version = "1" }
@ -75,22 +69,12 @@ objc2-foundation = "0.3.0"
block2 = "0.6.0"
objc2 = "0.6.0"
dispatch2 = "0.3.0"
rlimit = "0.11.0"
[target.'cfg(unix)'.dependencies]
nix = { version = "0.31", features = ["fs", "mount"] }
nix = { version = "0.30", features = ["fs"] }
[features]
# this feature is used for production builds or when `devPath` points to the filesystem and the built-in dev server is disabled.
# If you use cargo directly instead of tauri's cli you can use this feature flag to switch between tauri's `dev` and `build` modes.
# DO NOT REMOVE!!
custom-protocol = [ "tauri/custom-protocol" ]
no-self-updater = []
# Devtools
#
# Enables browser devtools for debugging javascript part.
# It's not recommended to enable this for production builds,
# development use only!
devtools = ["tauri/devtools"]

View file

@ -75,9 +75,9 @@ To build ALCOM, you need to have the following installed:
- [cargo] latest — to build the most part of the project
- And other requirements for tauri, see [tauri requirements](https://v2.tauri.app/start/prerequisites/#system-dependencies)
Please note that ALCOM requires the latest version of rust toolchain at that time.
Please note that ALCOM requires the latest version of cargo at that time.
We update the required version of cargo without notice.
Therefore, It's recommended to update rust toolchain before building the project.
Therefore, you may need to update them before building the project.
[Node.js]: https://nodejs.org/en
[npm]: https://www.npmjs.com
@ -88,27 +88,9 @@ Therefore, It's recommended to update rust toolchain before building the project
To build the project, run the following command:
```bash
cargo xtask build-alcom --release
npm run tauri build
```
This command builds the main ALCOM executable for the current platform.
For cross-compilation, add the `--target` command-line parameter.
The executable will be created in the `target/release` directory.
There are a few build options available when building ALCOM.
Most notably, you can disable the self-updater with the `--no-self-updater` option.
Note that this does not disable update checks.
ALCOM will show a message when a newer release is available instead of offering a self-update.
Directly distributing the executable may be suitable for some environments, but we also provide bundled distributions.
To bundle ALCOM, run the following command after building it.
```bash
cargo xtask bundle-alcom --release --bundles <bundles>
```
Check `--help` for the list of supported bundle types.
## Development
ALCOM is currently based on tauri and next.js.

View file

@ -6,3 +6,65 @@ beforeBuildCommand = "npm run build"
beforeDevCommand = "npm run dev"
devUrl = "http://localhost:3030"
frontendDist = "out"
[bundle]
active = true
targets = [
"appimage",
"deb",
"rpm",
"nsis", #-setup.exe
"app", # needs for dmg
"dmg",
]
longDescription = "ALCOM is a fast and open-source alternative VCC (VRChat Creator Companion) written in rust and tauri."
shortDescription = "ALCOM - Alternative Creator Companion"
category = "DeveloperTool"
copyright = "(c) anatawa12 and other contributors"
externalBin = []
icon = [
"icons/32x32.png",
"icons/64x64.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico",
]
resources = []
publisher = "anatawa12"
createUpdaterArtifacts = "v1Compatible" # remove if ci # we do not generate updater artifacts in CI
[[bundle.fileAssociations]]
# note: for macOS we directory use info.plist for registering file association.
description = "ALCOM Project Template"
ext = ['alcomtemplate']
mimeType = "application/x-alcom-template+json"
name = "ALCOM Project Template"
[bundle.linux.deb]
desktopTemplate = "alcom.desktop"
[bundle.linux.rpm]
desktopTemplate = "alcom.desktop"
[bundle.macOS]
exceptionDomain = ""
frameworks = []
providerShortName = "anatawa12"
[bundle.windows]
nsis.template = "installer.nsi"
# signing
certificateThumbprint = "0D17F6395EC64A2B1D341BB7AC5B3163EB148BB7"
timestampUrl = "http://ts.ssl.com"
digestAlgorithm = "sha256"
tsp = true
[plugins.updater]
endpoints = []
pubkey = "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDkyMjAzMkU2Q0ZGQjQ0MjYKUldRbVJQdlA1aklna2d2NnRoM3ZsT3lzWEQ3MC9zTGpaWVR4NGdQOXR0UGJaOHBlY2xCcFY5bHcK"
[app.security]

11
vrc-get-gui/alcom.desktop Normal file
View file

@ -0,0 +1,11 @@
[Desktop Entry]
Categories={{categories}}
{{#if comment}}
Comment={{comment}}
{{/if}}
Exec={{exec}} %u
Icon={{icon}}
Name={{name}}
Terminal=false
Type=Application
MimeType=x-scheme-handler/vcc

View file

@ -1,25 +1,20 @@
"use client"; // Error components must be Client Components
import { useEffect } from "react";
import { commands } from "@/lib/bindings";
import globalInfo from "@/lib/global-info";
import { useEffect } from "react";
export default function ErrorPage({
error,
}: {
error: object;
error: Error;
reset?: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
// When there is overridden toString, use it. if not, use stringify
const errorMessage =
error.toString === Object.prototype.toString
? JSON.stringify(error)
: error.toString();
const errorStack =
"stack" in error ? `${error.stack}` : "No stacktrace provided";
const errorMessage = `${error}`;
const errorStack = `${error.stack}`;
const openIssue = () => {
try {

View file

@ -2,9 +2,7 @@ import { LoaderCircle } from "lucide-react";
export default function Loading({
loadingText = "Loading...",
}: {
loadingText?: React.ReactNode;
}) {
}: { loadingText?: React.ReactNode }) {
return (
<div className="flex flex-col items-center justify-center h-full w-full space-y-4">
<LoaderCircle className="h-10 w-10 animate-spin" />

View file

@ -1,6 +1,6 @@
import { createRootRoute, Outlet } from "@tanstack/react-router";
import ErrorPage from "@/app/-error";
import { Providers } from "@/components/providers";
import { Outlet, createRootRoute } from "@tanstack/react-router";
import "./globals.css";
import React, { Suspense } from "react";

View file

@ -1,9 +1,8 @@
"use client";
import { createFileRoute } from "@tanstack/react-router";
import { HNavBar, HNavBarText, VStack } from "@/components/layout";
import { ScrollableCardTable } from "@/components/ScrollableCardTable";
import { ScrollPageContainer } from "@/components/ScrollPageContainer";
import { ScrollableCardTable } from "@/components/ScrollableCardTable";
import { HNavBar, VStack } from "@/components/layout";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
@ -18,13 +17,8 @@ import {
SelectValue,
} from "@/components/ui/select";
import { tc } from "@/lib/i18n";
import {
toastError,
toastInfo,
toastNormal,
toastSuccess,
toastWarning,
} from "@/lib/toast";
import { toastError, toastInfo, toastNormal, toastSuccess } from "@/lib/toast";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/_main/dev-palette/")({
component: Page,
@ -34,8 +28,12 @@ function Page() {
return (
<VStack>
<HNavBar
className="shrink-0"
leading={<HNavBarText>UI Palette (dev only)</HNavBarText>}
className={"shrink-0"}
leading={
<p className="cursor-pointer py-1.5 font-bold grow-0">
UI Palette (dev only)
</p>
}
/>
<ScrollPageContainer>
<main className="flex flex-col gap-2 shrink grow">
@ -119,12 +117,6 @@ function Page() {
>
Error
</Button>
<Button
variant={"warning"}
onClick={() => toastWarning("Warning Toast Body")}
>
Warning
</Button>
<Button
variant={"success"}
onClick={() => toastSuccess("Success Toast Body")}
@ -185,7 +177,7 @@ function UnityTableBody() {
</tr>
</thead>
<tbody>
{unityPaths.map(([path, version, _isFromHub]) => (
{unityPaths.map(([path, version, isFromHub]) => (
<tr key={path} className="even:bg-secondary/30">
<td className={"p-2.5"}>{version}</td>
<td className={"p-2.5"}>{path}</td>

View file

@ -1,8 +1,8 @@
import { BugOff, CircleX, Info, OctagonAlert } from "lucide-react";
import { memo, useEffect, useMemo, useRef } from "react";
import { ScrollableCardTable } from "@/components/ScrollableCardTable";
import type { LogEntry, LogLevel } from "@/lib/bindings";
import { tc } from "@/lib/i18n";
import { BugOff, CircleX, Info, OctagonAlert } from "lucide-react";
import { memo, useEffect, useMemo, useRef } from "react";
export const LogsListCard = memo(function LogsListCard({
logEntry,
@ -81,8 +81,12 @@ export const LogsListCard = memo(function LogsListCard({
);
});
const LogRow = memo(function LogRow({ log }: { log: LogEntry }) {
const cellClass = "p-2.5 compact:py-1";
const LogRow = memo(function LogRow({
log,
}: {
log: LogEntry;
}) {
const cellClass = "p-2.5";
const formatDate = (dateString: string) => {
const date = new Date(dateString);

View file

@ -1,16 +1,7 @@
"use client";
import {
queryOptions,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { ArrowDownFromLine } from "lucide-react";
import { useRef, useState } from "react";
import { HNavBar, HNavBarText, VStack } from "@/components/layout";
import { SearchBox } from "@/components/SearchBox";
import { HNavBar, VStack } from "@/components/layout";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
@ -32,6 +23,15 @@ import { tc } from "@/lib/i18n";
import { toastThrownError } from "@/lib/toast";
import { useTauriListen } from "@/lib/use-tauri-listen";
import { useSessionStorage } from "@/lib/useSessionStorage";
import {
queryOptions,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { ArrowDownFromLine } from "lucide-react";
import { useRef, useState } from "react";
import { LogsListCard } from "./-logs-list-card";
export const Route = createFileRoute("/_main/log/")({
@ -152,10 +152,10 @@ function ManageLogsHeading({
return (
<HNavBar
className="shrink-0"
className={"shrink-0"}
leading={
<>
<HNavBarText>{tc("logs")}</HNavBarText>
<p className="cursor-pointer py-1.5 font-bold grow-0">{tc("logs")}</p>
<SearchBox
className={"w-max grow"}
@ -169,7 +169,7 @@ function ManageLogsHeading({
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className={"shrink-0 p-3 compact:h-10"}>
<Button className={"shrink-0 p-3"}>
{tc("logs:manage:select logs level")}
</Button>
</DropdownMenuTrigger>
@ -207,7 +207,6 @@ function ManageLogsHeading({
</DropdownMenu>
<Button
className={"compact:h-10"}
onClick={() =>
commands.utilOpen(
`${globalInfo.vpmHomeFolder}/vrc-get/gui-logs`,
@ -223,11 +222,11 @@ function ManageLogsHeading({
<Button
variant={"ghost"}
onClick={() => handleLogAutoScrollChange(!autoScroll)}
className={`compact:h-10 ${
className={
autoScroll
? "bg-secondary border border-primary"
: "bg-transparent"
}`}
}
>
<ArrowDownFromLine className={"w-5 h-5"} />
</Button>

View file

@ -1,5 +1,5 @@
import { Link } from "@tanstack/react-router";
import { tc } from "@/lib/i18n";
import { Link } from "@tanstack/react-router";
type PageType =
| "/packages/user-packages"
@ -8,17 +8,15 @@ type PageType =
// Note: For historical reasons, templates page are under packages in route.
export function HeadingPageName({ pageType }: { pageType: PageType }) {
// Note for p-1 rounded-md -m-1 compact:m-0
// For normal mode, we use 1-unit of the outer padding for selector rectangle, so we use negative margin to eat padding.
// For compact mode, the height of the button is 2 units shorter than normal with the height of the navbar is remaining.
// Therefore we use the 1 unit space for outer padding for selector rectangle.
export function HeadingPageName({
pageType,
}: {
pageType: PageType;
}) {
return (
<div className={"flex compact:h-10 items-center"}>
<div className={"-ml-1.5"}>
<div
className={
"grid grid-cols-3 gap-1.5 bg-secondary p-1 rounded-md -m-1 compact:m-0"
}
className={"grid grid-cols-3 gap-1.5 bg-secondary p-1 -m-1 rounded-md"}
>
<HeadingButton
currentPage={pageType}
@ -53,7 +51,7 @@ function HeadingButton({
children: React.ReactNode;
}) {
const button =
"cursor-pointer px-3 py-2 font-bold grow-0 hover:bg-background rounded-sm text-center p-2 compact:h-8 compact:py-1";
"cursor-pointer px-3 py-2 font-bold grow-0 hover:bg-background rounded-sm text-center p-2";
if (currentPage === targetPage) {
return <div className={`${button} bg-background`}>{children}</div>;

View file

@ -1,12 +1,9 @@
import { queryOptions } from "@tanstack/react-query";
import type React from "react";
import { useState } from "react";
import {
ReorderableList,
useReorderableList,
} from "@/components/ReorderableList";
import { Button } from "@/components/ui/button";
import { DialogFooter } from "@/components/ui/dialog";
import { DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { assertNever } from "@/lib/assert-never";
import type {
@ -18,6 +15,41 @@ import { type DialogApi, type DialogContext, showDialog } from "@/lib/dialog";
import { tc, tt } from "@/lib/i18n";
import { queryClient } from "@/lib/query-client";
import { toastError, toastSuccess } from "@/lib/toast";
import { queryOptions } from "@tanstack/react-query";
import type React from "react";
import { useState } from "react";
type State =
| {
type: "normal";
}
| {
type: "enteringRepositoryInfo";
}
| {
type: "loadingRepository";
}
| {
type: "duplicated";
reason: TauriDuplicatedReason;
duplicatedName: string;
}
| {
type: "confirming";
repo: TauriRemoteRepositoryInfo;
url: string;
headers: { [key: string]: string };
};
interface AddRepository {
dialog: React.ReactNode;
openAddDialog: () => void;
inProgress: boolean;
addRepository: (
url: string,
headers: { [p: string]: string },
) => Promise<void>;
}
const environmentRepositoriesInfo = queryOptions({
queryKey: ["environmentRepositoriesInfo"],
@ -156,7 +188,7 @@ function EnteringRepositoryInfo({
return (
<>
<div>
<DialogDescription>
<p className={"font-normal"}>
{tc("vpm repositories:dialog:enter repository info")}
</p>
@ -242,7 +274,7 @@ function EnteringRepositoryInfo({
{tc("vpm repositories:hint:duplicate headers")}
</p>
)}
</div>
</DialogDescription>
<DialogFooter>
<Button onClick={() => dialog.close(null)}>
{tc("general:button:cancel")}
@ -259,12 +291,16 @@ function EnteringRepositoryInfo({
);
}
function LoadingRepository({ cancel }: { cancel: () => void }) {
function LoadingRepository({
cancel,
}: {
cancel: () => void;
}) {
return (
<>
<div>
<DialogDescription>
<p>{tc("vpm repositories:dialog:downloading...")}</p>
</div>
</DialogDescription>
<DialogFooter>
<Button onClick={cancel}>{tc("general:button:cancel")}</Button>
</DialogFooter>
@ -303,10 +339,10 @@ function Duplicated({
return (
<>
<div>
<DialogDescription>
<p>{tc("vpm repositories:dialog:already added")}</p>
<p>{message}</p>
</div>
</DialogDescription>
<DialogFooter>
<Button onClick={() => dialog.close()}>
{tc("general:button:ok")}
@ -328,7 +364,7 @@ function Confirming({
return (
<>
{/* TODO: use ScrollArea (I failed to use it inside dialog) */}
<div className={"max-h-[50vh] overflow-y-auto font-normal"}>
<DialogDescription className={"max-h-[50vh] overflow-y-auto font-normal"}>
<p className={"font-normal"}>
{tc("vpm repositories:dialog:name", { name: repo.display_name })}
</p>
@ -341,7 +377,7 @@ function Confirming({
{tc("vpm repositories:dialog:headers")}
</p>
<ul className={"list-disc pl-6"}>
{Object.entries(headers).map(([key, value]) => (
{Object.entries(headers).map(([key, value], idx) => (
<li key={key}>
{key}: {value}
</li>
@ -353,11 +389,11 @@ function Confirming({
{tc("vpm repositories:dialog:packages")}
</p>
<ul className={"list-disc pl-6"}>
{repo.packages.map((info) => (
{repo.packages.map((info, idx) => (
<li key={info.name}>{info.display_name ?? info.name}</li>
))}
</ul>
</div>
</DialogDescription>
<DialogFooter>
<Button onClick={() => dialog.close(false)}>
{tc("general:button:cancel")}

View file

@ -1,6 +1,3 @@
import { queryOptions } from "@tanstack/react-query";
import type React from "react";
import { useEffect, useRef, useState } from "react";
import {
Accordion,
AccordionContent,
@ -8,7 +5,7 @@ import {
AccordionTrigger,
} from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import { DialogFooter } from "@/components/ui/dialog";
import { DialogDescription, DialogFooter } from "@/components/ui/dialog";
import { Progress } from "@/components/ui/progress";
import { assertNever } from "@/lib/assert-never";
import type {
@ -22,6 +19,10 @@ import { tc, tt } from "@/lib/i18n";
import { queryClient } from "@/lib/query-client";
import { toastSuccess } from "@/lib/toast";
import { useEffectEvent } from "@/lib/use-effect-event";
import { queryOptions } from "@tanstack/react-query";
import type React from "react";
import { useEffect, useRef } from "react";
import { useState } from "react";
type ParsedRepositories = {
repositories: TauriRepositoryDescriptor[];
@ -93,7 +94,7 @@ function ConfirmingRepositoryList({
return (
<>
{/* TODO: use ScrollArea (I failed to use it inside dialog) */}
<div className={"max-h-[50vh] overflow-y-auto font-normal"}>
<DialogDescription className={"max-h-[50vh] overflow-y-auto font-normal"}>
<p className={"font-normal whitespace-normal"}>
{tc("vpm repositories:dialog:confirm repository list")}
</p>
@ -119,7 +120,7 @@ function ConfirmingRepositoryList({
</ul>
</>
)}
</div>
</DialogDescription>
<DialogFooter className={"gap-2"}>
<Button onClick={() => dialog.close(null)}>
{tc("general:button:cancel")}
@ -159,7 +160,7 @@ function LoadingRepositories({
return (
<>
<div>
<DialogDescription>
<p>{tc("vpm repositories:dialog:downloading repositories...")}</p>
<Progress value={downloaded} max={totalCount} />
<div className={"text-center"}>
@ -168,7 +169,7 @@ function LoadingRepositories({
totalCount,
})}
</div>
</div>
</DialogDescription>
<DialogFooter>
<Button onClick={() => cancelRef.current?.()}>
{tc("general:button:cancel")}
@ -196,7 +197,7 @@ function ConfirmingPackages({
return (
<>
{/* TODO: use ScrollArea (I failed to use it inside dialog) */}
<div className={"font-normal"}>
<DialogDescription className={"font-normal"}>
<p className={"whitespace-normal"}>
{tc("vpm repositories:dialog:confirm packages list")}
</p>
@ -227,7 +228,7 @@ function ConfirmingPackages({
error = false;
content = (
<ul className={"list-disc pl-6"}>
{download.value.packages.map((info) => (
{download.value.packages.map((info, idx) => (
<li key={info.name}>{info.display_name ?? info.name}</li>
))}
</ul>
@ -249,7 +250,7 @@ function ConfirmingPackages({
);
})}
</Accordion>
</div>
</DialogDescription>
<DialogFooter>
<Button onClick={() => dialog.close(null)}>
{tc("general:button:cancel")}
@ -264,8 +265,10 @@ function ConfirmingPackages({
function AddingRepositories() {
return (
<div>
<p>{tc("vpm repositories:dialog:adding repositories...")}</p>
</div>
<>
<DialogDescription>
<p>{tc("vpm repositories:dialog:adding repositories...")}</p>
</DialogDescription>
</>
);
}

View file

@ -1,48 +1,14 @@
"use client";
import {
type CollisionDetection,
closestCenter,
DndContext,
type DragEndEvent,
type DragOverEvent,
DragOverlay,
type DragStartEvent,
defaultDropAnimation,
defaultDropAnimationSideEffects,
type Modifier,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import {
queryOptions,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { ChevronDown, CircleX, GripVertical } from "lucide-react";
import {
Suspense,
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { HNavBar, VStack } from "@/components/layout";
import { ScrollableCardTable } from "@/components/ScrollableCardTable";
import { HNavBar, VStack } from "@/components/layout";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { DialogFooter, DialogTitle } from "@/components/ui/dialog";
import {
DialogDescription,
DialogFooter,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
@ -62,6 +28,15 @@ import { usePrevPathName } from "@/lib/prev-page";
import { toastThrownError } from "@/lib/toast";
import { useTauriListen } from "@/lib/use-tauri-listen";
import { cn } from "@/lib/utils";
import {
queryOptions,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { ChevronDown, CircleX } from "lucide-react";
import { Suspense, useCallback, useEffect, useId, useMemo } from "react";
import { HeadingPageName } from "../-tab-selector";
import { addRepository, openAddRepositoryDialog } from "./-use-add-repository";
import { importRepositories } from "./-use-import-repositories";
@ -70,8 +45,6 @@ export const Route = createFileRoute("/_main/packages/repositories/")({
component: Page,
});
type UserRepoWithListId = TauriUserRepository & { listId: string };
function Page() {
return (
<Suspense>
@ -80,99 +53,11 @@ function Page() {
);
}
const restrictToVerticalAxis: Modifier = ({ transform }) => ({
...transform,
x: 0,
});
const DRAG_OVERLAY_MODIFIERS = [restrictToVerticalAxis];
const customDropAnimation: typeof defaultDropAnimation = {
...defaultDropAnimation,
sideEffects: defaultDropAnimationSideEffects({
styles: {
active: { opacity: "0" },
},
}),
};
const TABLE_HEAD = [
"", // checkbox
"general:name",
"vpm repositories:url",
"", // actions
"", // grip handle
] as const;
const environmentRepositoriesInfo = queryOptions({
queryKey: ["environmentRepositoriesInfo"],
queryFn: commands.environmentRepositoriesInfo,
});
// Scrolls the given viewport element when the pointer is near the top or bottom
// edge during drag. dnd-kit's built-in autoscroll is disabled because it causes
// jitter with Radix UI ScrollArea (wrong container detection + double-smoothing).
function useDragAutoScroll(
viewportRef: React.RefObject<HTMLElement | null>,
isActive: boolean,
): void {
useEffect(() => {
if (!isActive) return;
const THRESHOLD = 80; // px from edge to begin scrolling
const MAX_SPEED = 15; // px/frame at the very edge
let pointerY = 0;
const onPointerMove = (e: PointerEvent) => {
pointerY = e.clientY;
};
window.addEventListener("pointermove", onPointerMove, { passive: true });
let rafId: number;
const tick = () => {
const viewport = viewportRef.current;
if (viewport) {
const { top, bottom } = viewport.getBoundingClientRect();
const distFromTop = pointerY - top;
const distFromBottom = bottom - pointerY;
let delta = 0;
if (distFromTop >= 0 && distFromTop < THRESHOLD) {
delta = -MAX_SPEED * (1 - distFromTop / THRESHOLD);
} else if (distFromBottom >= 0 && distFromBottom < THRESHOLD) {
delta = MAX_SPEED * (1 - distFromBottom / THRESHOLD);
}
if (delta !== 0) {
viewport.scrollTo({
top: viewport.scrollTop + delta,
behavior: "instant",
});
}
}
rafId = requestAnimationFrame(tick);
};
rafId = requestAnimationFrame(tick);
return () => {
window.removeEventListener("pointermove", onPointerMove);
cancelAnimationFrame(rafId);
};
}, [isActive, viewportRef]);
}
function computeSlotKey(repo: TauriUserRepository, used: Set<string>): string {
const base = `${repo.id} ${repo.url ?? ""}`;
let key = base;
let counter = 0;
while (used.has(key)) {
counter++;
key = `${base} ${counter}`;
}
used.add(key);
return key;
}
function PageBody() {
const result = useQuery(environmentRepositoriesInfo);
@ -214,110 +99,140 @@ function PageBody() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const guiAnimation = useQuery({
queryKey: ["environmentGuiAnimation"],
queryFn: commands.environmentGuiAnimation,
initialData: true,
}).data;
const bodyAnimation = usePrevPathName().startsWith("/packages")
? "slide-right"
: "";
const userRepos = result.data?.user_repositories;
const listIdMapRef = useRef<Map<string, string>>(new Map());
const augmentedUserRepos = useMemo<UserRepoWithListId[]>(() => {
if (!userRepos) {
listIdMapRef.current = new Map();
return [];
}
const prev = listIdMapRef.current;
const next = new Map<string, string>();
const usedKeys = new Set<string>();
const result: UserRepoWithListId[] = [];
for (const r of userRepos) {
const key = computeSlotKey(r, usedKeys);
const listId = prev.get(key) ?? crypto.randomUUID();
next.set(key, listId);
result.push({ ...r, listId });
}
listIdMapRef.current = next;
return result;
}, [userRepos]);
const [orderedListIds, setOrderedListIds] = useState<string[]>(() =>
augmentedUserRepos.map((r) => r.listId),
return (
<VStack>
<HNavBar
className={"shrink-0"}
leading={<HeadingPageName pageType={"/packages/repositories"} />}
trailing={
<DropdownMenu>
<div className={"flex divide-x"}>
<Button
className={"rounded-r-none"}
onClick={() => openAddRepositoryDialog()}
>
{tc("vpm repositories:button:add repository")}
</Button>
<DropdownMenuTrigger
asChild
className={"rounded-l-none pl-2 pr-2"}
>
<Button>
<ChevronDown className={"w-4 h-4"} />
</Button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent>
<DropdownMenuItem
onClick={() => importRepositoriesMutation.mutate()}
>
{tc("vpm repositories:button:import repositories")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => exportRepositories.mutate()}>
{tc("vpm repositories:button:export repositories")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
}
/>
<main
className={`shrink overflow-hidden flex w-full h-full ${bodyAnimation}`}
>
<ScrollableCardTable className={"h-full w-full"}>
<RepositoryTableBody
userRepos={result.data?.user_repositories || []}
hiddenUserRepos={hiddenUserRepos}
/>
</ScrollableCardTable>
</main>
</VStack>
);
}
useEffect(() => {
setOrderedListIds(augmentedUserRepos.map((r) => r.listId));
}, [augmentedUserRepos]);
function RepositoryTableBody({
userRepos,
hiddenUserRepos,
}: {
userRepos: TauriUserRepository[];
hiddenUserRepos: Set<string>;
}) {
const TABLE_HEAD = [
"", // checkbox
"general:name",
"vpm repositories:url",
"", // actions
];
const userRepoByListId = useMemo(
() => new Map(augmentedUserRepos.map((r) => [r.listId, r])),
[augmentedUserRepos],
return (
<>
<thead>
<tr>
{TABLE_HEAD.map((head, index) => (
<th
// biome-ignore lint/suspicious/noArrayIndexKey: static array
key={index}
className={
"sticky top-0 z-10 border-b border-primary bg-secondary text-secondary-foreground p-2.5"
}
>
<small className="font-normal leading-none">{tc(head)}</small>
</th>
))}
</tr>
</thead>
<tbody>
<RepositoryRow
repoId={"com.vrchat.repos.official"}
url={"https://packages.vrchat.com/official?download"}
displayName={tt("vpm repositories:source:official")}
hiddenUserRepos={hiddenUserRepos}
canRemove={false}
/>
<RepositoryRow
repoId={"com.vrchat.repos.curated"}
url={"https://packages.vrchat.com/curated?download"}
displayName={tt("vpm repositories:source:curated")}
hiddenUserRepos={hiddenUserRepos}
className={"border-b border-primary/10"}
canRemove={false}
/>
{userRepos.map((repo) => (
<RepositoryRow
key={repo.id}
repoId={repo.id}
displayName={repo.display_name}
url={repo.url}
hiddenUserRepos={hiddenUserRepos}
/>
))}
</tbody>
</>
);
}
const userRepoByListIdRef =
useRef<Map<string, UserRepoWithListId>>(userRepoByListId);
useEffect(() => {
userRepoByListIdRef.current = userRepoByListId;
}, [userRepoByListId]);
const [activeId, setActiveId] = useState<string | null>(null);
const [overId, setOverId] = useState<string | null>(null);
const [columnWidths, setColumnWidths] = useState<number[]>([]);
const theadRowRef = useRef<HTMLTableRowElement>(null);
const scrollViewportRef = useRef<HTMLDivElement>(null);
const sensors = useSensors(useSensor(PointerSensor));
const orderedListIdsSet = useMemo(
() => new Set(orderedListIds),
[orderedListIds],
);
const collisionDetection = useCallback<CollisionDetection>(
(args) =>
closestCenter({
...args,
droppableContainers: args.droppableContainers.filter((c) =>
orderedListIdsSet.has(c.id as string),
),
}),
[orderedListIdsSet],
);
function RepositoryRow({
repoId,
displayName,
url,
hiddenUserRepos,
className,
canRemove = true,
}: {
repoId: TauriUserRepository["id"];
displayName: TauriUserRepository["display_name"];
url: TauriUserRepository["url"];
hiddenUserRepos: Set<string>;
className?: string;
canRemove?: boolean;
}) {
const cellClass = "p-2.5";
const id = useId();
const queryClient = useQueryClient();
const reorderMutation = useMutation({
mutationFn: (listIds: string[]) => {
const repos = listIds
.map((lid) => userRepoByListId.get(lid))
.filter((r): r is UserRepoWithListId => r !== undefined)
.map((r) => ({ index: r.index, id: r.id }));
return commands.environmentReorderRepositories(repos);
},
// Pin listIds to the new positions so duplicate-keyed rows don't swap their listIds on refetch.
onMutate: (newListIds: string[]) => {
const prevMap = new Map(listIdMapRef.current);
const rebuilt = new Map<string, string>();
const usedKeys = new Set<string>();
for (const lid of newListIds) {
const repo = userRepoByListIdRef.current.get(lid);
if (!repo) continue;
const key = computeSlotKey(repo, usedKeys);
rebuilt.set(key, lid);
}
listIdMapRef.current = rebuilt;
return { prevMap };
},
onSettled: () => queryClient.invalidateQueries(environmentRepositoriesInfo),
onError: (e, _newListIds, ctx) => {
if (ctx?.prevMap) listIdMapRef.current = ctx.prevMap;
toastThrownError(e);
},
});
const setHideRepository = useMutation({
mutationFn: async ({ id, shown }: { id: string; shown: boolean }) => {
if (shown) {
@ -362,483 +277,68 @@ function PageBody() {
},
});
const activeVisualIndex = useMemo(() => {
if (!activeId) return 0;
const effectiveId = overId ?? activeId;
return orderedListIds.indexOf(effectiveId) + 2; // +2 for the 2 fixed rows
}, [activeId, overId, orderedListIds]);
function handleDragStart(event: DragStartEvent) {
setActiveId(event.active.id as string);
if (theadRowRef.current) {
const widths = Array.from(
theadRowRef.current.querySelectorAll("th"),
(th) => th.getBoundingClientRect().width,
);
setColumnWidths(widths);
}
}
function handleDragOver(event: DragOverEvent) {
setOverId((event.over?.id as string | null) ?? null);
}
function handleDragEnd(event: DragEndEvent) {
setActiveId(null);
setOverId(null);
const { active, over } = event;
if (over && active.id !== over.id) {
const oldIndex = orderedListIds.indexOf(active.id as string);
const newIndex = orderedListIds.indexOf(over.id as string);
const newListIds = arrayMove(orderedListIds, oldIndex, newIndex);
setOrderedListIds(newListIds);
reorderMutation.mutate(newListIds);
}
}
function handleDragCancel() {
setActiveId(null);
setOverId(null);
}
useDragAutoScroll(scrollViewportRef, activeId !== null);
const bodyAnimation = usePrevPathName().startsWith("/packages")
? "slide-right"
: "";
return (
<DndContext
sensors={sensors}
collisionDetection={collisionDetection}
autoScroll={false}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<VStack>
<div style={activeId !== null ? { pointerEvents: "none" } : undefined}>
<HNavBar
className="shrink-0"
leading={<HeadingPageName pageType={"/packages/repositories"} />}
trailing={
<DropdownMenu>
<div className={"flex divide-x"}>
<Button
className={"rounded-r-none compact:h-10"}
onClick={() => openAddRepositoryDialog()}
>
{tc("vpm repositories:button:add repository")}
</Button>
<DropdownMenuTrigger
asChild
className={"rounded-l-none pl-2 pr-2 compact:h-10"}
>
<Button>
<ChevronDown className={"w-4 h-4"} />
</Button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent>
<DropdownMenuItem
onClick={() => importRepositoriesMutation.mutate()}
>
{tc("vpm repositories:button:import repositories")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => exportRepositories.mutate()}>
{tc("vpm repositories:button:export repositories")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
}
/>
</div>
<main
className={`shrink overflow-hidden flex w-full h-full ${bodyAnimation}`}
>
<ScrollableCardTable
className={"h-full w-full"}
viewportRef={scrollViewportRef}
>
<RepositoryTableBody
orderedListIds={orderedListIds}
userRepoByListId={userRepoByListId}
hiddenUserRepos={hiddenUserRepos}
theadRowRef={theadRowRef}
guiAnimation={guiAnimation}
onToggleVisibility={(id, shown) =>
setHideRepository.mutate({ id, shown })
}
isDragActive={activeId !== null}
/>
</ScrollableCardTable>
</main>
</VStack>
<DragOverlay
modifiers={DRAG_OVERLAY_MODIFIERS}
dropAnimation={guiAnimation ? customDropAnimation : null}
>
{activeId ? (
<RepositoryDragOverlay
repo={userRepoByListId.get(activeId)}
selected={
!hiddenUserRepos.has(userRepoByListId.get(activeId)?.id ?? "")
}
columnWidths={columnWidths}
visualIndex={activeVisualIndex}
guiAnimation={guiAnimation}
/>
) : null}
</DragOverlay>
</DndContext>
);
}
function RepositoryTableBody({
orderedListIds,
userRepoByListId,
hiddenUserRepos,
theadRowRef,
guiAnimation,
onToggleVisibility,
isDragActive,
}: {
orderedListIds: string[];
userRepoByListId: Map<string, UserRepoWithListId>;
hiddenUserRepos: Set<string>;
theadRowRef: React.RefObject<HTMLTableRowElement | null>;
guiAnimation: boolean;
onToggleVisibility: (id: string, shown: boolean) => void;
isDragActive: boolean;
}) {
return (
<>
<thead>
<tr ref={theadRowRef}>
{TABLE_HEAD.map((head, index) => (
<th
// biome-ignore lint/suspicious/noArrayIndexKey: static array
key={index}
className={
"sticky top-0 z-10 border-b border-primary bg-secondary text-secondary-foreground px-2.5 py-1.5"
}
>
<small className="font-normal leading-none">{tc(head)}</small>
</th>
))}
</tr>
</thead>
<tbody>
<RepositoryRow
repoId={"com.vrchat.repos.official"}
url={"https://packages.vrchat.com/official?download"}
displayName={tt("vpm repositories:source:official")}
hiddenUserRepos={hiddenUserRepos}
canRemove={false}
rowIndex={0}
guiAnimation={guiAnimation}
onToggleVisibility={onToggleVisibility}
isDragActive={isDragActive}
/>
<RepositoryRow
repoId={"com.vrchat.repos.curated"}
url={"https://packages.vrchat.com/curated?download"}
displayName={tt("vpm repositories:source:curated")}
hiddenUserRepos={hiddenUserRepos}
className={"border-b border-primary/10"}
canRemove={false}
rowIndex={1}
guiAnimation={guiAnimation}
onToggleVisibility={onToggleVisibility}
isDragActive={isDragActive}
/>
<SortableContext
items={orderedListIds}
strategy={verticalListSortingStrategy}
>
{orderedListIds.map((listId, index) => {
const repo = userRepoByListId.get(listId);
if (!repo) return null;
return (
<RepositoryRow
key={listId}
listId={listId}
repoId={repo.id}
repoIndex={repo.index}
displayName={repo.display_name}
url={repo.url}
hiddenUserRepos={hiddenUserRepos}
rowIndex={2 + index}
guiAnimation={guiAnimation}
onToggleVisibility={onToggleVisibility}
isDragActive={isDragActive}
/>
);
})}
</SortableContext>
</tbody>
</>
);
}
const CELL_CLASS = "p-2.5 compact:py-1 align-middle";
function RepositoryRowCells({
labelId,
displayName,
url,
canRemove,
selected,
onCheckedChange,
onRemove,
dragListeners,
dragAttributes,
}: {
labelId?: string;
displayName: string;
url: string | null | undefined;
canRemove: boolean;
selected: boolean;
onCheckedChange?: (shown: boolean) => void;
onRemove?: () => void;
dragListeners?: ReturnType<typeof useSortable>["listeners"];
dragAttributes?: ReturnType<typeof useSortable>["attributes"];
}) {
const interactive = onCheckedChange !== undefined;
return (
<>
<td className={CELL_CLASS}>
{interactive ? (
<div className="flex">
<Checkbox
id={labelId}
checked={selected}
onCheckedChange={(x) => onCheckedChange(x === true)}
/>
</div>
) : (
<div className="pointer-events-none flex">
<Checkbox checked={selected} />
</div>
)}
</td>
<td className={CELL_CLASS}>
{interactive ? (
<label htmlFor={labelId}>
<p className="font-normal">{displayName}</p>
</label>
) : (
<p className="font-normal">{displayName}</p>
)}
</td>
<td className={CELL_CLASS}>
<p className="font-normal">{url}</p>
</td>
<td className={`${CELL_CLASS} w-0`}>
{interactive ? (
<Tooltip>
<TooltipTrigger asChild={canRemove}>
<Button
disabled={!canRemove}
onClick={onRemove}
variant={"ghost"}
size={"icon"}
>
<CircleX className={"size-5 text-destructive"} />
</Button>
</TooltipTrigger>
<TooltipContent>
{canRemove
? tc("vpm repositories:remove repository")
: tc(
"vpm repositories:tooltip:remove curated or official repository",
)}
</TooltipContent>
</Tooltip>
) : (
<Button variant={"ghost"} size={"icon"} disabled>
<CircleX className={"size-5 text-destructive"} />
</Button>
)}
</td>
<td
className={cn(
CELL_CLASS,
"w-0",
canRemove ? "cursor-move" : "cursor-not-allowed",
)}
{...(canRemove ? dragListeners : undefined)}
{...(canRemove ? dragAttributes : undefined)}
>
<GripVertical
className={cn(
"size-5 text-muted-foreground",
!canRemove && "opacity-50",
)}
/>
</td>
</>
);
}
function RepositoryRow({
listId,
repoId,
repoIndex,
displayName,
url,
hiddenUserRepos,
className,
canRemove = true,
rowIndex,
guiAnimation,
onToggleVisibility,
isDragActive,
}: {
listId?: string;
repoId: TauriUserRepository["id"];
repoIndex?: number;
displayName: TauriUserRepository["display_name"];
url: TauriUserRepository["url"];
hiddenUserRepos: Set<string>;
className?: string;
canRemove?: boolean;
rowIndex: number;
guiAnimation: boolean;
onToggleVisibility: (id: string, shown: boolean) => void;
isDragActive: boolean;
}) {
const labelId = useId();
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: listId ?? repoId, disabled: !canRemove });
const visualIndex = useMemo(() => {
if (isDragging) return rowIndex;
const dy = transform?.y ?? 0;
if (dy < 0) return rowIndex - 1;
if (dy > 0) return rowIndex + 1;
return rowIndex;
}, [rowIndex, transform?.y, isDragging]);
const dragStyle = useMemo<React.CSSProperties>(
() => ({
transform: transform ? `translateY(${transform.y}px)` : undefined,
transition: guiAnimation
? [transition, isDragActive ? undefined : "background-color 200ms ease"]
.filter(Boolean)
.join(", ") || undefined
: undefined,
opacity: isDragging ? 0 : 1,
position: "relative",
}),
[transform, transition, isDragging, guiAnimation, isDragActive],
);
const selected = !hiddenUserRepos.has(repoId);
return (
<tr
ref={setNodeRef}
style={dragStyle}
className={cn(visualIndex % 2 === 1 ? "bg-secondary/30" : "", className)}
>
<RepositoryRowCells
labelId={labelId}
displayName={displayName}
url={url}
canRemove={canRemove}
selected={selected}
onCheckedChange={(shown) => onToggleVisibility(repoId, shown)}
onRemove={() =>
void openSingleDialog(RemoveRepositoryDialog, {
displayName,
index: repoIndex ?? 0,
id: repoId,
})
}
dragListeners={listeners}
dragAttributes={attributes}
/>
<tr className={cn("even:bg-secondary/30", className)}>
<td className={cellClass}>
<Checkbox
id={id}
checked={selected}
onCheckedChange={(x) =>
setHideRepository.mutate({ id: repoId, shown: x === true })
}
/>
</td>
<td className={cellClass}>
<label htmlFor={id}>
<p className="font-normal">{displayName}</p>
</label>
</td>
<td className={cellClass}>
<p className="font-normal">{url}</p>
</td>
<td className={`${cellClass} w-0`}>
<Tooltip>
<TooltipTrigger asChild={canRemove}>
<Button
disabled={!canRemove}
onClick={() => {
void openSingleDialog(RemoveRepositoryDialog, {
displayName,
id: repoId,
});
}}
variant={"ghost"}
size={"icon"}
>
<CircleX className={"size-5 text-destructive"} />
</Button>
</TooltipTrigger>
<TooltipContent>
{canRemove
? tc("vpm repositories:remove repository")
: tc(
"vpm repositories:tooltip:remove curated or official repository",
)}
</TooltipContent>
</Tooltip>
</td>
</tr>
);
}
function RepositoryDragOverlay({
repo,
selected,
columnWidths,
visualIndex,
guiAnimation,
}: {
repo: TauriUserRepository | undefined;
selected: boolean;
columnWidths: number[];
visualIndex: number;
guiAnimation: boolean;
}) {
const style = useMemo<React.CSSProperties>(
() => ({
transition: guiAnimation ? "background-color 200ms ease" : undefined,
}),
[guiAnimation],
);
if (!repo) return null;
return (
<table
className={cn(
"w-full table-fixed text-left",
visualIndex % 2 === 1 ? "bg-secondary/30" : "",
)}
style={style}
>
{columnWidths.length > 0 && (
<colgroup>
{columnWidths.map((w, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: fixed column order
<col key={i} style={{ width: w }} />
))}
</colgroup>
)}
<tbody>
<tr>
<RepositoryRowCells
displayName={repo.display_name}
url={repo.url}
canRemove={true}
selected={selected}
/>
</tr>
</tbody>
</table>
);
}
function RemoveRepositoryDialog({
dialog,
displayName,
index,
id,
}: {
dialog: DialogContext<void>;
displayName: string;
index: number;
id: string;
}) {
}: { dialog: DialogContext<void>; displayName: string; id: string }) {
const queryClient = useQueryClient();
const removeRepository = useMutation({
mutationFn: async (args: { index: number; id: string }) =>
await commands.environmentRemoveRepository(args.index, args.id),
onMutate: async ({ index }) => {
mutationFn: async (id: string) =>
await commands.environmentRemoveRepository(id),
onMutate: async (id) => {
await queryClient.cancelQueries(environmentRepositoriesInfo);
const data = queryClient.getQueryData(
environmentRepositoriesInfo.queryKey,
@ -846,30 +346,22 @@ function RemoveRepositoryDialog({
if (data !== undefined) {
queryClient.setQueryData(environmentRepositoriesInfo.queryKey, {
...data,
user_repositories: data.user_repositories.filter(
(x) => x.index !== index,
),
user_repositories: data.user_repositories.filter((x) => x.id !== id),
});
}
return data;
},
onError: (e, _args, ctx) => {
queryClient.setQueryData(environmentRepositoriesInfo.queryKey, ctx);
toastThrownError(e);
},
onSettled: () => queryClient.invalidateQueries(environmentRepositoriesInfo),
});
return (
<>
<DialogTitle>{tc("vpm repositories:remove repository")}</DialogTitle>
<div>
<DialogDescription>
<p className={"whitespace-normal font-normal"}>
{tc("vpm repositories:dialog:confirm remove description", {
name: displayName,
})}
</p>
</div>
</DialogDescription>
<DialogFooter>
<Button onClick={() => dialog.close()}>
{tc("general:button:cancel")}
@ -877,7 +369,7 @@ function RemoveRepositoryDialog({
<Button
onClick={() => {
dialog.close();
removeRepository.mutate({ index, id });
removeRepository.mutate(id);
}}
className={"ml-2"}
>

View file

@ -1,33 +1,18 @@
import {
queryOptions,
useMutation,
useQuery,
useQueryClient,
useSuspenseQuery,
} from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { ChevronDown, CircleX, Ellipsis, Star } from "lucide-react";
import type React from "react";
import { Suspense, useId, useMemo, useState } from "react";
import { HeadingPageName } from "@/app/_main/packages/-tab-selector";
import Loading from "@/app/-loading";
import { FilePathRow } from "@/components/common-setting-parts";
import { FavoriteStarToggleButton } from "@/components/FavoriteStarButton";
import { HNavBar, VStack } from "@/components/layout";
import { HeadingPageName } from "@/app/_main/packages/-tab-selector";
import { Overlay } from "@/components/Overlay";
import {
ReorderableList,
type ReorderableListId,
useReorderableList,
} from "@/components/ReorderableList";
import { ScrollableCardTable } from "@/components/ScrollableCardTable";
import { TemplateSelect } from "@/components/TemplateSelect";
import {
type AutoCompleteOption,
Autocomplete,
} from "@/components/ui/autocomplete";
import { HNavBar, VStack } from "@/components/layout";
import { Button } from "@/components/ui/button";
import { DialogFooter, DialogTitle } from "@/components/ui/dialog";
import {
DialogDescription,
DialogFooter,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
@ -35,32 +20,45 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { assertNever } from "@/lib/assert-never";
import {
commands,
type TauriAlcomTemplate,
type TauriProjectTemplateInfo,
type TauriVersion,
commands,
} from "@/lib/bindings";
import { dateToString, formatDateOffset } from "@/lib/dateToString";
import { type DialogContext, openSingleDialog } from "@/lib/dialog";
import { tc, tt } from "@/lib/i18n";
import { tc } from "@/lib/i18n";
import { processResult } from "@/lib/import-templates";
import { usePrevPathName } from "@/lib/prev-page";
import {
type ProjectTemplateCategory,
projectTemplateCategory,
projectTemplateDisplayId,
projectTemplateName,
} from "@/lib/project-template";
import { toastError, toastSuccess, toastThrownError } from "@/lib/toast";
import { toastSuccess, toastThrownError } from "@/lib/toast";
import { cn } from "@/lib/utils";
import { compareVersion } from "@/lib/version";
import {
queryOptions,
useQuery,
useQueryClient,
useSuspenseQuery,
} from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { ChevronDown, CircleX, Ellipsis } from "lucide-react";
import type React from "react";
import { Suspense, useId, useState } from "react";
export const Route = createFileRoute("/_main/packages/templates/")({
component: RouteComponent,
@ -83,15 +81,15 @@ function RouteComponent() {
return (
<VStack>
<HNavBar
className="shrink-0"
className={"shrink-0"}
leading={<HeadingPageName pageType={"/packages/templates"} />}
trailing={
<DropdownMenu>
<div className={"flex divide-x"}>
<CreateTemplateButton className={"rounded-r-none compact:h-10"} />
<CreateTemplateButton className={"rounded-r-none"} />
<DropdownMenuTrigger
asChild
className={"rounded-l-none pl-2 pr-2 compact:h-10"}
className={"rounded-l-none pl-2 pr-2"}
>
<Button>
<ChevronDown className={"w-4 h-4"} />
@ -141,7 +139,6 @@ function TemplatesTableBody() {
await openSingleDialog(TemplateEditor, {
templates: information.data.templates,
template: { ...alcomTemplate, id },
favoriteTemplates: information.data.favorite_templates,
});
} catch (e) {
console.error(e);
@ -164,45 +161,16 @@ function TemplatesTableBody() {
}
};
const templatesOrdered = useMemo(() => {
const perCategoryFav: {
[K in `${boolean}-${ProjectTemplateCategory}`]: TauriProjectTemplateInfo[];
} = {
"true-builtin": [],
"false-builtin": [],
"true-alcom": [],
"false-alcom": [],
"true-vcc": [],
"false-vcc": [],
};
for (const template of information.data.templates) {
const category = projectTemplateCategory(template.id);
const favorite = information.data.favorite_templates.includes(
template.id,
);
perCategoryFav[`${favorite}-${category}`].push(template);
}
return (["builtin", "alcom", "vcc"] as const).flatMap((category) => [
...perCategoryFav[`true-${category}`],
...perCategoryFav[`false-${category}`],
]);
}, [information.data.templates, information.data.favorite_templates]);
return (
<>
<thead>
<tr>
<th
className={`sticky top-0 z-10 border-b border-primary bg-secondary text-secondary-foreground px-2.5 py-1.5`}
>
<Star className={"size-4"} />
</th>
{TABLE_HEAD.map((head, index) => (
<th
// biome-ignore lint/suspicious/noArrayIndexKey: static array
key={index}
className={
"sticky top-0 z-10 border-b border-primary bg-secondary text-secondary-foreground px-2.5 py-1.5"
"sticky top-0 z-10 border-b border-primary bg-secondary text-secondary-foreground p-2.5"
}
>
<small className="font-normal leading-none">{tc(head)}</small>
@ -211,13 +179,12 @@ function TemplatesTableBody() {
</tr>
</thead>
<tbody>
{templatesOrdered.map((template) => (
{information.data.templates.map((template) => (
<TemplateRow
key={template.id}
template={template}
remove={removeTemplate}
edit={editTemplate}
favorite={information.data.favorite_templates.includes(template.id)}
/>
))}
</tbody>
@ -229,14 +196,12 @@ function TemplateRow({
template,
remove,
edit,
favorite,
}: {
template: TauriProjectTemplateInfo;
remove?: (id: string) => void;
edit?: (id: string) => void;
favorite: boolean;
}) {
const cellClass = "p-2.5 compact:py-1";
const cellClass = "p-2.5";
const id = useId();
const category = projectTemplateCategory(template.id);
@ -253,64 +218,8 @@ function TemplateRow({
}
};
const queryClient = useQueryClient();
const setTemplateFavorite = useMutation({
mutationFn: (params: { id: string; favorite: boolean }) =>
commands.environmentSetTemplateFavorite(params.id, params.favorite),
onMutate: async (params) => {
await queryClient.cancelQueries(environmentProjectCreationInformation);
const previousData = queryClient.getQueryData(
environmentProjectCreationInformation.queryKey,
);
if (previousData !== undefined) {
queryClient.setQueryData(
environmentProjectCreationInformation.queryKey,
{
...previousData,
favorite_templates: params.favorite
? previousData.favorite_templates.includes(params.id)
? previousData.favorite_templates
: [...previousData.favorite_templates, params.id]
: previousData.favorite_templates.filter((x) => x !== params.id),
},
);
}
return previousData;
},
onError: (error, _, context) => {
console.error("Error favoriting project", error);
toastThrownError(error);
if (context) {
queryClient.setQueryData(
environmentProjectCreationInformation.queryKey,
context,
);
}
},
});
return (
<tr className="even:bg-secondary/30 group">
<td className={`${cellClass} w-3`}>
<div className={"relative flex"}>
<FavoriteStarToggleButton
favorite={favorite}
disabled={category === "vcc"}
onToggle={() =>
setTemplateFavorite.mutate({
id: template.id,
favorite: !favorite,
})
}
/>
</div>
</td>
<tr className="even:bg-secondary/30">
<td className={`${cellClass} w-full`}>
<label htmlFor={id}>
<p className="font-normal">{projectTemplateName(template)}</p>
@ -377,16 +286,13 @@ function TemplateRow({
function RemoveTemplateConfirmDialog({
dialog,
displayName,
}: {
dialog: DialogContext<boolean>;
displayName: string;
}) {
}: { dialog: DialogContext<boolean>; displayName: string }) {
return (
<>
<DialogTitle>{tc("templates:dialog:remove template")}</DialogTitle>
<div>
<DialogDescription>
{tc("templates:dialog:confirm remove template", { displayName })}
</div>
</DialogDescription>
<DialogFooter>
<Button onClick={() => dialog.close(false)}>
{tc("general:button:cancel")}
@ -507,7 +413,6 @@ function CreateTemplateButton({ className }: { className: string }) {
void openSingleDialog(TemplateEditor, {
templates: information.data.templates,
template: null,
favoriteTemplates: information.data.favorite_templates,
});
}
}}
@ -523,25 +428,18 @@ const prereleaseSegment = regexp`(?:0|[1-9]\d*|[0-9a-z-]*[a-z-][0-9a-z-]*)`;
const prerelease = regexp`(?:-?${prereleaseSegment}(?:\.${prereleaseSegment})*)`;
const buildSegment = regexp`(?:[0-9a-z-]+)`;
const build = regexp`(?:${buildSegment}(?:\.${buildSegment})*)`;
const packageRangeRegex = new RegExp(
const rangeRegex = new RegExp(
regexp`^\s*(?:(?:>|<|>=|<=|=|\^|~)\s*)?v?${versionSegment}(?:\.${versionSegment}(?:\.${versionSegment}${prerelease}?${build}?)?)?\s*$`,
"i",
);
// Currently, the unity version channel part and increment part is ignored and not allowed to include
const unityRangeRegex = new RegExp(
regexp`^\s*(?:(?:>|<|>=|<=|=|\^|~)\s*)?v?${versionSegment}(?:\.${versionSegment}(?:\.${versionSegment})?)?\s*$`,
"i",
);
function TemplateEditor({
templates,
template,
favoriteTemplates,
dialog,
}: {
templates: TauriProjectTemplateInfo[];
template: (TauriAlcomTemplate & { id: string }) | null;
favoriteTemplates: string[];
dialog: DialogContext<boolean>;
}) {
const [baseTemplate, setBaseTemplate] = useState<string>(
@ -550,183 +448,6 @@ function TemplateEditor({
const [name, setName] = useState(template?.display_name ?? "");
const [unityRange, setUnityRange] = useState(template?.unity_version ?? "");
const allPackages = useQuery({
queryKey: ["environmentPackages"],
queryFn: () => commands.environmentPackages(),
});
const { packageCandidates, versionCandidatePerPackage } = useMemo(() => {
type PackageInfo = {
dataSourceVersion: TauriVersion;
displayName: string | null;
keywords: string[];
versions: TauriVersion[];
};
const packages = new Map<string, PackageInfo>();
for (const pkg of allPackages.data ?? []) {
if (pkg.is_yanked) continue;
let rowInfo = packages.get(pkg.name);
if (
rowInfo == null ||
compareVersion(pkg.version, rowInfo.dataSourceVersion) > 0
) {
packages.set(
pkg.name,
(rowInfo = {
dataSourceVersion: pkg.version,
displayName: pkg.display_name,
keywords: pkg.keywords,
versions: rowInfo?.versions ?? [],
}),
);
}
rowInfo.versions.push(pkg.version);
}
return {
packageCandidates: Array.from(packages.entries()).map(
([id, pkg]) =>
({
value: id,
label: (
<AutocompletePackageLabel displayName={pkg.displayName} id={id} />
),
keywords: [pkg.displayName, ...pkg.keywords].filter(
(x) => x != null,
),
}) satisfies AutoCompleteOption,
),
versionCandidatePerPackage: new Map(
Array.from(packages.entries()).map(([id, pkg]) => {
// we generate few candidates for version per package
// - '*' for any version
// - '>=latestStable' and '>=latestPrerelease'
// - '^latestStable' and '^latestPrerelease'
// - '1.x' '1.2.x' (or something like this) for stable release
const latestStable = pkg.versions
.filter((x) => x.pre === "")
.sort(compareVersion)
.at(-1);
const latestPrerelease = pkg.versions.sort(compareVersion).at(-1);
const candidates: AutoCompleteOption[] = [];
function addCandidate(value: string, description: React.ReactNode) {
candidates.push({
value,
label: (
<AutocompleteVersionLabel
value={value}
description={description}
/>
),
});
}
addCandidate("*", tc("templates:dialog:any version"));
if (latestStable != null) {
addCandidate(
`${latestStable.major}.x`,
`${latestStable.major}.0.0 ≤ v < ${latestStable.major + 1}.0.0`,
);
addCandidate(
`${latestStable.major}.${latestStable.minor}.x`,
`${latestStable.major}.${latestStable.minor}.0 ≤ v < ${latestStable.major}.${latestStable.minor + 1}.0`,
);
addCandidate(
`${latestStable.major}.${latestStable.minor}.${latestStable.patch}`,
`v = ${latestStable.major}.${latestStable.minor}.${latestStable.patch}`,
);
addCandidate(
`>=${latestStable.major}.${latestStable.minor}.${latestStable.patch}`,
`v ≥ ${latestStable.major}.${latestStable.minor}.${latestStable.patch}`,
);
addCandidate(
`^${latestStable.major}.${latestStable.minor}.${latestStable.patch}`,
`${latestStable.major}.${latestStable.minor}.${latestStable.patch} ≤ v < ${hatEndVersion(latestStable)}`,
);
}
if (latestPrerelease != null && latestPrerelease !== latestStable) {
addCandidate(
`${latestPrerelease.major}.${latestPrerelease.minor}.${latestPrerelease.patch}-${latestPrerelease.pre}`,
`v = ${latestPrerelease.major}.${latestPrerelease.minor}.${latestPrerelease.patch}-${latestPrerelease.pre}`,
);
addCandidate(
`>=${latestPrerelease.major}.${latestPrerelease.minor}.${latestPrerelease.patch}-${latestPrerelease.pre}`,
`v ≥ ${latestPrerelease.major}.${latestPrerelease.minor}.${latestPrerelease.patch}-${latestPrerelease.pre}`,
);
addCandidate(
`^${latestPrerelease.major}.${latestPrerelease.minor}.${latestPrerelease.patch}-${latestPrerelease.pre}`,
`${latestPrerelease.major}.${latestPrerelease.minor}.${latestPrerelease.patch}-${latestPrerelease.pre} ≤ v < ${hatEndVersion(latestPrerelease)}`,
);
}
function hatEndVersion(version: TauriVersion): string {
return version.major === 0 && version.minor === 0
? `${version.major}.${version.minor}.${version.patch + 1}`
: version.major === 0
? `${version.major}.${version.minor + 1}.0`
: `${version.major + 1}.0.0`;
}
return [id, candidates];
}),
),
};
}, [allPackages.data]);
const unityCandidates = useMemo(() => {
const templateInfo = templates.find((x) => x.id === baseTemplate);
if (templateInfo == null) return [];
// unityVersions is in order
// currently, ignore the unity version channel part and increment part
const unityVersions = templateInfo.unity_versions.map(
(x) => x.split(/[^\d.]/, 2)[0],
);
const candidates: AutoCompleteOption[] = [];
function addCandidate(value: string, description: React.ReactNode) {
candidates.push({
value,
label: (
<AutocompleteVersionLabel value={value} description={description} />
),
});
}
candidates.push(...unityVersions);
addCandidate("*", tc("templates:dialog:any version"));
// create something like 2022.x and 2022.3.x
const addedRange = new Set<string>();
for (const unityVersion of unityVersions) {
const majorOnly = unityVersion.match(/^\d+/)?.[0];
const minor = unityVersion.match(/^\d+\.\d+/)?.[0];
if (majorOnly && !addedRange.has(majorOnly)) {
addedRange.add(majorOnly);
addCandidate(
`${majorOnly}.x`,
tc("templates:dialog:any unity specified version", {
version: majorOnly,
}),
);
}
if (minor && !addedRange.has(minor)) {
addedRange.add(minor);
addCandidate(
`${minor}.x`,
tc("templates:dialog:any unity specified version", {
version: minor,
}),
);
}
}
return candidates;
}, [templates, baseTemplate]);
type Package = { name: string; range: string };
const packagesListContext = useReorderableList<Package>({
defaultValue: { name: "", range: "" },
@ -741,11 +462,6 @@ function TemplateEditor({
reorderable: false,
});
const addedPackageNames = useMemo(
() => new Set(packagesListContext.value.map((p) => p.name)),
[packagesListContext.value],
);
const unityPackagesListContext = useReorderableList<string>({
defaultValue: "",
defaultArray: template?.unity_packages ?? [],
@ -756,7 +472,7 @@ function TemplateEditor({
const addUnityPackages = async () => {
try {
const packages = await commands.environmentPickUnityPackages();
const packages = await commands.environmentPickUnityPackage();
for (const pkg of packages) {
unityPackagesListContext.add(pkg);
}
@ -766,31 +482,6 @@ function TemplateEditor({
}
};
const pickUnityPackage = async (
currentValue: string,
currentId: ReorderableListId,
) => {
try {
const result = await commands.environmentPickUnityPackage(currentValue);
switch (result.type) {
case "NoFolderSelected":
// no-op
break;
case "InvalidSelection":
toastError(tt("general:toast:invalid file"));
break;
case "Successful":
unityPackagesListContext.update(currentId, result.new_path);
break;
default:
assertNever(result);
}
} catch (e) {
console.error(e);
toastThrownError(e);
}
};
const queryClient = useQueryClient();
const saveTemplate = async () => {
try {
@ -816,10 +507,10 @@ function TemplateEditor({
const validVersion = (p: Package) =>
(p.name === "" && p.range === "") || // the empty (non-set) row
(p.name !== "" && p.range.match(packageRangeRegex)); // ready to create
(p.name !== "" && p.range.match(rangeRegex)); // ready to create
const readyToCreate =
packagesListContext.value.every(validVersion) &&
unityRange.match(unityRangeRegex) &&
unityRange.match(rangeRegex) &&
name.length !== 0;
return (
@ -829,170 +520,182 @@ function TemplateEditor({
? tc("templates:dialog:edit template")
: tc("templates:dialog:create template")}
</DialogTitle>
<div className={"flex flex-col gap-4 shrink min-h-0"}>
<section>
<h3 className={"font-bold w-full text-center content-center"}>
{tc("templates:dialog:general information")}
</h3>
<table className={"grid grid-cols-[min-content_1fr] gap-x-4 gap-y-1"}>
<tbody className={"contents"}>
<tr className={"contents"}>
<th className={"content-center text-start whitespace-nowrap"}>
{tc("general:name")}:
</th>
<td className={"flex"}>
<Input
className={cn(
"grow",
name.length === 0 &&
"border-destructive ring-destructive text-destructive",
)}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={"Your New Template"}
/>
</td>
</tr>
<tr className={"contents"}>
<th className={"content-center text-start whitespace-nowrap"}>
{tc("templates:dialog:base template")}:
</th>
<td className={"flex"}>
<TemplateSelect
value={baseTemplate}
onValueChange={setBaseTemplate}
templates={templates}
favoriteTemplates={favoriteTemplates}
className={"grow"}
excludeNoIdTemplates
/>
</td>
</tr>
<tr className={"contents"}>
<th className={"content-center text-start whitespace-nowrap"}>
{tc("templates:dialog:unity version")}:
</th>
<td className={"flex"}>
<Autocomplete
className={cn(
"grow",
unityRange.match(unityRangeRegex) ||
"border-destructive ring-destructive text-destructive",
)}
value={unityRange}
onChange={(value) => setUnityRange(value)}
options={unityCandidates}
/>
</td>
</tr>
</tbody>
</table>
</section>
<section className={"shrink overflow-hidden flex flex-col"}>
<h3 className={"font-bold w-full text-center content-center"}>
{tc("general:packages")}
</h3>
<div className={"w-full max-h-[30vh] overflow-y-auto shrink"}>
<table className={"w-full"}>
<thead>
<tr>
<th className={"sticky top-0 z-10 bg-background"}>
{tc("general:name")}
</th>
<th className={"sticky top-0 z-10 bg-background"}>
{tc("general:version")}
</th>
<th className={"sticky top-0 z-10 bg-background"} />
</tr>
</thead>
<tbody>
<ReorderableList
context={packagesListContext}
renderItem={(value, id) => (
<>
<td>
<div className={"flex"}>
<Autocomplete
value={value.name}
className={"grow"}
options={packageCandidates.filter(
(c) =>
c.value === value.name ||
!addedPackageNames.has(c.value),
)}
onChange={(value) =>
packagesListContext.update(id, (old) => ({
...old,
name: value,
}))
}
/>
</div>
</td>
<td>
<div className={"flex"}>
<Autocomplete
value={value.range}
className={cn(
"grow",
validVersion(value) ||
"border-destructive ring-destructive text-destructive",
)}
options={
versionCandidatePerPackage.get(value.name) ?? []
}
onChange={(value) =>
packagesListContext.update(id, (old) => ({
...old,
range: value,
}))
}
/>
</div>
</td>
</>
)}
/>
</tbody>
</table>
</div>
</section>
<section className={"shrink overflow-hidden flex flex-col"}>
<Overlay>
<DialogDescription asChild>
<div className={"flex flex-col gap-4 shrink min-h-0"}>
<section>
<h3 className={"font-bold w-full text-center content-center"}>
{tc("templates:dialog:unitypackages")}
{tc("templates:dialog:general information")}
</h3>
<div className={"text-right mb-2"}>
<Button onClick={addUnityPackages}>
{tc("general:button:add")}
</Button>
</div>
</Overlay>
<div className={"w-full max-h-[30vh] overflow-y-auto shrink"}>
<table className={"w-full"}>
<tbody>
<ReorderableList
context={unityPackagesListContext}
ifEmpty={() => (
<td className={"text-center"}>
{tc("templates:dialog:no unitypackages")}
</td>
)}
renderItem={(value, id) => (
<td>
<FilePathRow
path={value}
pick={() => pickUnityPackage(value, id).finally()}
withOpen={false}
/>
</td>
)}
/>
<table
className={"grid grid-cols-[min-content_1fr] gap-x-4 gap-y-1"}
>
<tbody className={"contents"}>
<tr className={"contents"}>
<th className={"content-center text-start whitespace-nowrap"}>
{tc("general:name")}:
</th>
<td className={"flex"}>
<Input
className={cn(
"grow",
name.length === 0 &&
"border-destructive ring-destructive text-destructive",
)}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={"Your New Template"}
/>
</td>
</tr>
<tr className={"contents"}>
<th className={"content-center text-start whitespace-nowrap"}>
{tc("templates:dialog:base template")}:
</th>
<td className={"flex"}>
<Select
value={baseTemplate}
onValueChange={setBaseTemplate}
>
<SelectTrigger>
<SelectValue className={"grow"} />
</SelectTrigger>
<SelectContent>
{templates.map((template) => {
const id = projectTemplateDisplayId(template.id);
if (id == null) return null;
return (
<SelectItem key={id} value={id}>
{projectTemplateName(template)}
</SelectItem>
);
})}
</SelectContent>
</Select>
</td>
</tr>
<tr className={"contents"}>
<th className={"content-center text-start whitespace-nowrap"}>
{tc("templates:dialog:unity version")}:
</th>
<td className={"flex"}>
<Input
className={cn(
"grow",
unityRange.match(rangeRegex) ||
"border-destructive ring-destructive text-destructive",
)}
value={unityRange}
onChange={(e) => setUnityRange(e.target.value)}
placeholder={">=2022 * =2022.3.22"}
/>
</td>
</tr>
</tbody>
</table>
</div>
</section>
</div>
</section>
<section className={"shrink overflow-hidden flex flex-col"}>
<h3 className={"font-bold w-full text-center content-center"}>
{tc("general:packages")}
</h3>
<div className={"w-full max-h-[30vh] overflow-y-auto shrink"}>
<table className={"w-full"}>
<thead>
<tr>
<th className={"sticky top-0 z-10 bg-background"}>
{tc("general:name")}
</th>
<th className={"sticky top-0 z-10 bg-background"}>
{tc("general:version")}
</th>
<th className={"sticky top-0 z-10 bg-background"} />
</tr>
</thead>
<tbody>
<ReorderableList
context={packagesListContext}
renderItem={(value, id) => (
<>
<td>
<div className={"flex"}>
<Input
type={"text"}
value={value.name}
className={"grow"}
onChange={(e) =>
packagesListContext.update(id, (old) => ({
...old,
name: e.target.value,
}))
}
/>
</div>
</td>
<td>
<div className={"flex"}>
<Input
type={"text"}
value={value.range}
className={cn(
"grow",
validVersion(value) ||
"border-destructive ring-destructive text-destructive",
)}
onChange={(e) =>
packagesListContext.update(id, (old) => ({
...old,
range: e.target.value,
}))
}
/>
</div>
</td>
</>
)}
/>
</tbody>
</table>
</div>
</section>
<section className={"shrink overflow-hidden flex flex-col"}>
<Overlay>
<h3 className={"font-bold w-full text-center content-center"}>
{tc("templates:dialog:unitypackages")}
</h3>
<div className={"text-right mb-2"}>
<Button onClick={addUnityPackages}>
{tc("general:button:add")}
</Button>
</div>
</Overlay>
<div className={"w-full max-h-[30vh] overflow-y-auto shrink"}>
<table className={"w-full"}>
<tbody>
<ReorderableList
context={unityPackagesListContext}
ifEmpty={() => (
<td className={"text-center"}>
{tc("templates:dialog:no unitypackages")}
</td>
)}
renderItem={(value) => (
<td>
<div className={"flex"}>
<Input
type={"text"}
value={value}
className={"grow"}
disabled
/>
</div>
</td>
)}
/>
</tbody>
</table>
</div>
</section>
</div>
</DialogDescription>
<DialogFooter className={"mt-2"}>
<Button onClick={() => dialog.close(false)}>
{tc("general:button:cancel")}
@ -1008,34 +711,3 @@ function TemplateEditor({
</div>
);
}
function AutocompletePackageLabel({
displayName,
id,
}: {
displayName: string | null;
id: string;
}) {
if (displayName == null) return id;
return (
<div className={"flex flex-col"}>
<div>{displayName}</div>
<div className={"text-xs text-muted-foreground"}>{id}</div>
</div>
);
}
function AutocompleteVersionLabel({
value,
description,
}: {
value: string;
description: React.ReactNode;
}) {
return (
<div className={"flex flex-row justify-between w-full"}>
<div>{value}</div>
<div className={"text-xs text-muted-foreground"}>{description}</div>
</div>
);
}

View file

@ -1,21 +1,13 @@
"use client";
import {
queryOptions,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { CircleX } from "lucide-react";
import { Suspense, useId } from "react";
import { HNavBar, VStack } from "@/components/layout";
import { ScrollableCardTable } from "@/components/ScrollableCardTable";
import { HNavBar, VStack } from "@/components/layout";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogTitle,
DialogTrigger,
@ -31,6 +23,15 @@ import { tc } from "@/lib/i18n";
import { usePrevPathName } from "@/lib/prev-page";
import { toastError, toastSuccess, toastThrownError } from "@/lib/toast";
import { toVersionString } from "@/lib/version";
import {
queryOptions,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { CircleX } from "lucide-react";
import { Suspense, useId } from "react";
import { HeadingPageName } from "../-tab-selector";
export const Route = createFileRoute("/_main/packages/user-packages/")({
@ -86,13 +87,10 @@ function PageBody() {
return (
<VStack>
<HNavBar
className="shrink-0"
className={"shrink-0"}
leading={<HeadingPageName pageType={"/packages/user-packages"} />}
trailing={
<Button
className={"compact:h-10"}
onClick={() => addUserPackageWithPicker.mutate()}
>
<Button onClick={() => addUserPackageWithPicker.mutate()}>
{tc("user packages:button:add package")}
</Button>
}
@ -156,7 +154,7 @@ function RepositoryTableBody({
// biome-ignore lint/suspicious/noArrayIndexKey: static array
key={index}
className={
"sticky top-0 z-10 border-b border-primary bg-secondary text-secondary-foreground px-2.5 py-1.5"
"sticky top-0 z-10 border-b border-primary bg-secondary text-secondary-foreground p-2.5"
}
>
<small className="font-normal leading-none">{tc(head)}</small>
@ -184,7 +182,7 @@ function PackageRow({
pkg: TauriUserPackage;
remove: () => void;
}) {
const cellClass = "p-2.5 compact:py-1";
const cellClass = "p-2.5";
const id = useId();
const pkgDisplayNames = pkg.package.display_name ?? pkg.package.name;
@ -219,14 +217,14 @@ function PackageRow({
<DialogTitle>
{tc("user packages:dialog:remove package")}
</DialogTitle>
<div>
<DialogDescription>
<p className={"whitespace-normal font-normal"}>
{tc("user packages:dialog:confirm remove description", {
name: pkgDisplayNames,
path: pkg.path,
})}
</p>
</div>
</DialogDescription>
<DialogFooter>
<DialogClose asChild>
<Button>{tc("general:button:cancel")}</Button>

View file

@ -1,19 +1,26 @@
import { useMutation } from "@tanstack/react-query";
import { RefreshCw } from "lucide-react";
import type React from "react";
import { useEffect, useId, useMemo, useState } from "react";
import { VStack } from "@/components/layout";
import { TemplateSelect } from "@/components/TemplateSelect";
import { Button } from "@/components/ui/button";
import { DialogFooter, DialogTitle } from "@/components/ui/dialog";
import {
DialogDescription,
DialogFooter,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { assertNever } from "@/lib/assert-never";
import type { TauriProjectTemplateInfo } from "@/lib/bindings";
import { commands } from "@/lib/bindings";
@ -25,8 +32,20 @@ import {
ProjectNameCheckResult,
useProjectNameCheck,
} from "@/lib/project-name-check";
import {
type ProjectTemplateCategory,
projectTemplateCategory,
projectTemplateName,
} from "@/lib/project-template";
import { queryClient } from "@/lib/query-client";
import { toastError, toastSuccess, toastThrownError } from "@/lib/toast";
import { useMutation } from "@tanstack/react-query";
import { RefreshCw } from "lucide-react";
import type React from "react";
import { useEffect } from "react";
import { useMemo } from "react";
import { useId } from "react";
import { useState } from "react";
export async function createProject() {
const information = await commands.environmentProjectCreationInformation();
@ -34,8 +53,6 @@ export async function createProject() {
using dialog = showDialog();
const result = await dialog.ask(EnteringInformation, {
templates: information.templates,
favoriteTemplates: information.favorite_templates,
lastUsedTemplate: information.last_used_template,
projectLocation: information.default_path,
recentProjectLocations: information.recent_project_locations,
});
@ -53,6 +70,7 @@ export async function createProject() {
);
dialog.close();
toastSuccess(tt("projects:toast:project created"));
close?.();
await queryClient.invalidateQueries({
queryKey: ["environmentProjects"],
});
@ -75,7 +93,7 @@ function DialogBase({
return (
<>
<DialogTitle>{tc("projects:create new project")}</DialogTitle>
<div>{children}</div>
<DialogDescription>{children}</DialogDescription>
<DialogFooter className={"gap-2"}>
<Button onClick={close} disabled={!close}>
{tc("general:button:cancel")}
@ -99,39 +117,23 @@ function EnteringInformation({
templates,
projectLocation: projectLocationFirst,
recentProjectLocations: recentProjectLocationsReversed,
favoriteTemplates,
lastUsedTemplate,
dialog,
}: {
templates: TauriProjectTemplateInfo[];
projectLocation: string;
favoriteTemplates: string[];
lastUsedTemplate: string | null;
recentProjectLocations: string[];
dialog: DialogContext<null | ProjectCreationInformation>;
}) {
const [unityVersion, setUnityVersion] = useState<string>(
templates[0].unity_versions[0],
);
const [templateId, setTemplateId] = useState<string>(templates[0].id);
const templateById = useMemo(
() => new Map(templates.map((t) => [t.id, t])),
[templates],
);
const [templateId, setTemplateId] = useState<string>(() => {
const template = lastUsedTemplate
? templateById.get(lastUsedTemplate)
: undefined;
return template?.available &&
template.unity_versions.length !== 0 &&
lastUsedTemplate != null
? lastUsedTemplate
: templates[0].id;
});
const [unityVersion, setUnityVersion] = useState<string>(
() =>
templateById.get(templateId)?.unity_versions?.[0] ??
templates[0].unity_versions[0],
);
const [projectNameRaw, setProjectName] = useState("New Project");
const projectName = projectNameRaw.trim();
const [projectLocation, setProjectLocation] = useState(projectLocationFirst);
@ -178,6 +180,28 @@ function EnteringInformation({
const templateInputId = useId();
const unityInputId = useId();
const templatesByCategory = useMemo(() => {
const byCategory: {
[k in ProjectTemplateCategory]: TauriProjectTemplateInfo[];
} = {
builtin: [],
alcom: [],
vcc: [],
};
for (const template of templates) {
byCategory[projectTemplateCategory(template.id)].push(template);
}
return (
[
["builtin", byCategory.builtin],
["alcom", byCategory.alcom],
["vcc", byCategory.vcc],
] satisfies [ProjectTemplateCategory, TauriProjectTemplateInfo[]][]
).filter((x) => x[1].length > 0);
}, [templates]);
const unityVersions = templateById.get(templateId)?.unity_versions ?? [];
const badProjectName = ["AlreadyExists", "InvalidNameForFolderName"].includes(
@ -207,13 +231,59 @@ function EnteringInformation({
<div className={"flex items-center whitespace-nowrap"}>
<label htmlFor={templateInputId}>{tc("projects:template")}</label>
</div>
<TemplateSelect
<Select
value={templateId}
onValueChange={setTemplateId}
templates={templates}
favoriteTemplates={favoriteTemplates}
selectTriggerId={templateInputId}
/>
onValueChange={(value) => setTemplateId(value)}
>
<SelectTrigger id={templateInputId}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{templatesByCategory.map(([category, templates], index) => (
<SelectGroup key={category}>
{index !== 0 && <SelectSeparator />}
<SelectLabel>
{tc(`projects:template-category:${category}`)}
</SelectLabel>
{templates.map((template) => {
const disabled =
!template.available ||
template.unity_versions.length === 0;
const contents = (
<SelectItem
value={template.id}
disabled={disabled}
key={template.id}
>
{projectTemplateName(template)}
</SelectItem>
);
if (!template.available) {
return (
<Tooltip key={template.id}>
<TooltipTrigger>{contents}</TooltipTrigger>
<TooltipContent>
{tc("projects:tooltip:template-unavailable")}
</TooltipContent>
</Tooltip>
);
} else if (template.unity_versions.length === 0) {
return (
<Tooltip key={template.id}>
<TooltipTrigger>{contents}</TooltipTrigger>
<TooltipContent>
{tc("projects:tooltip:template-no-unity")}
</TooltipContent>
</Tooltip>
);
} else {
return contents;
}
})}
</SelectGroup>
))}
</SelectContent>
</Select>
</div>
<div className={"flex items-center gap-1 whitespace-nowrap"}>
<label htmlFor={unityInputId}>
@ -316,10 +386,8 @@ function UnityVersion({
function CreatingProject() {
return (
<DialogBase>
<div className={"flex items-center gap-2"}>
<RefreshCw className={"w-5 h-5 animate-spin"} />
<p>{tc("projects:creating project...")}</p>
</div>
<RefreshCw className={"w-5 h-5 animate-spin"} />
<p>{tc("projects:creating project...")}</p>
</DialogBase>
);
}

View file

@ -1,16 +1,15 @@
import { CircleHelp, CircleUserRound, Ellipsis, Globe } from "lucide-react";
import {
ButtonDisabledIfInvalid,
getProjectDisplayInfo,
FavoriteToggleButton,
ManageOrMigrateButton,
ProjectContext,
TooltipTriggerIfInvalid,
TooltipTriggerIfValid,
getProjectDisplayInfo,
useSetProjectFavoriteMutation,
} from "@/app/_main/projects/-project-row";
import { copyProject } from "@/app/_main/projects/manage/-copy-project";
import { BackupProjectDialog } from "@/components/BackupProjectDialog";
import { FavoriteStarToggleButton } from "@/components/FavoriteStarButton";
import { OpenUnityButton } from "@/components/OpenUnityButton";
import { RemoveProjectDialog } from "@/components/RemoveProjectDialog";
import { Button } from "@/components/ui/button";
@ -29,14 +28,17 @@ import {
} from "@/components/ui/tooltip";
import type { TauriProject } from "@/lib/bindings";
import { commands } from "@/lib/bindings";
import {
dateToString,
dayToString,
formatDateOffset,
} from "@/lib/dateToString";
import { dateToString, formatDateOffset } from "@/lib/dateToString";
import { openSingleDialog } from "@/lib/dialog";
import { tc } from "@/lib/i18n";
import { toastThrownError } from "@/lib/toast";
import { queryOptions } from "@tanstack/react-query";
import { CircleHelp, CircleUserRound, Ellipsis, Globe } from "lucide-react";
const environmentProjects = queryOptions({
queryKey: ["environmentProjects"],
queryFn: commands.environmentProjects,
});
export function ProjectGridItem({
project,
@ -49,7 +51,7 @@ export function ProjectGridItem({
const typeIconClass = "w-5 h-5";
const { projectTypeKind, displayType, isLegacy, createdAt, lastModified } =
const { projectTypeKind, displayType, isLegacy, lastModified } =
getProjectDisplayInfo(project);
const removed = !project.is_exists;
@ -59,11 +61,11 @@ export function ProjectGridItem({
<ProjectContext.Provider
value={{ removed, is_valid, loading: Boolean(loading) }}
>
<Card className="relative p-4 bg-card flex flex-col gap-2 group compact:p-2 compact:pl-3 compact:gap-1">
<Card className="relative p-4 bg-card flex flex-col gap-2 group">
<div className={"absolute top-2 right-2 gap-2 flex"}>
<div className="relative content-center">
<FavoriteStarToggleButton
favorite={project.favorite}
<FavoriteToggleButton
project={project}
disabled={removed || loading}
onToggle={() =>
setProjectFavorite.mutate({
@ -126,7 +128,14 @@ export function ProjectGridItem({
<p className="font-normal whitespace-pre overflow-ellipsis overflow-hidden">
{project.name}
</p>
<p className="font-normal opacity-50 text-sm whitespace-pre overflow-ellipsis overflow-hidden compact:hidden">
</TooltipTriggerIfValid>
<TooltipContent>{project.name}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTriggerIfValid
className={"text-left select-text cursor-auto w-full"}
>
<p className="font-normal opacity-50 text-sm whitespace-pre overflow-ellipsis overflow-hidden">
{project.path}
</p>
</TooltipTriggerIfValid>
@ -169,47 +178,25 @@ export function ProjectGridItem({
</div>
</div>
<div className="flex flex-row gap-1">
<div className="text-xs text-muted-foreground">
{tc("general:created at")}:{" "}
<Tooltip>
<TooltipTrigger>
<time dateTime={createdAt.toISOString()}>
<time className="font-normal">
{dayToString(project.created_at)}
</time>
<div className="text-xs text-muted-foreground">
{tc("general:last modified")}:{" "}
<Tooltip>
<TooltipTrigger>
<time dateTime={lastModified.toISOString()}>
<time className="font-normal">
{formatDateOffset(project.last_modified)}
</time>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
{dateToString(project.created_at)}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
<p className="text-xs text-muted-foreground">/</p>
<div className="text-xs text-muted-foreground">
{tc("general:last modified")}:{" "}
<Tooltip>
<TooltipTrigger>
<time dateTime={lastModified.toISOString()}>
<time className="font-normal">
{formatDateOffset(project.last_modified)}
</time>
</time>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
{dateToString(project.last_modified)}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
</time>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
{dateToString(project.last_modified)}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
<div className="mt-2 flex flex-wrap gap-2 justify-end compact:gap-1">
<div className="mt-2 flex flex-wrap gap-2 justify-end">
<ButtonDisabledIfInvalid asChild>
<OpenUnityButton
projectPath={project.path}

View file

@ -1,19 +1,14 @@
import {
queryOptions,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { CircleHelp, CircleUserRound, Ellipsis, Globe } from "lucide-react";
import React, { type ComponentProps, useContext } from "react";
import { copyProject } from "@/app/_main/projects/manage/-copy-project";
import { MigrationCopyingDialog } from "@/app/_main/projects/manage/-unity-migration";
import { BackupProjectDialog } from "@/components/BackupProjectDialog";
import { FavoriteStarToggleButton } from "@/components/FavoriteStarButton";
import { OpenUnityButton } from "@/components/OpenUnityButton";
import { RemoveProjectDialog } from "@/components/RemoveProjectDialog";
import { Button } from "@/components/ui/button";
import { DialogFooter, DialogTitle } from "@/components/ui/dialog";
import {
DialogDescription,
DialogFooter,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
@ -29,17 +24,28 @@ import {
import { assertNever } from "@/lib/assert-never";
import type { TauriProject, TauriProjectType } from "@/lib/bindings";
import { commands } from "@/lib/bindings";
import {
dateToString,
dayToString,
formatDateOffset,
} from "@/lib/dateToString";
import { dateToString, formatDateOffset } from "@/lib/dateToString";
import { type DialogContext, openSingleDialog, showDialog } from "@/lib/dialog";
import { tc, tt } from "@/lib/i18n";
import { router } from "@/lib/main";
import { queryClient } from "@/lib/query-client";
import { toastError, toastSuccess, toastThrownError } from "@/lib/toast";
import { cn } from "@/lib/utils";
import { compareUnityVersionString } from "@/lib/version";
import {
queryOptions,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import {
CircleHelp,
CircleUserRound,
Ellipsis,
Globe,
Star,
} from "lucide-react";
import React, { type ComponentProps, useContext } from "react";
export const ProjectDisplayType: Record<
TauriProjectType,
@ -78,11 +84,11 @@ export function ProjectRow({
project: TauriProject;
loading?: boolean;
}) {
const cellClass = "p-2.5 compact:py-1";
const cellClass = "p-2.5";
const noGrowCellClass = `${cellClass} w-1`;
const typeIconClass = "w-5 h-5";
const { projectTypeKind, displayType, isLegacy, createdAt, lastModified } =
const { projectTypeKind, displayType, isLegacy, lastModified } =
getProjectDisplayInfo(project);
const openProjectFolder = () =>
@ -109,10 +115,10 @@ export function ProjectRow({
<tr
className={`group even:bg-secondary/30 ${removed || loading || !(project.is_valid ?? true) ? "opacity-50" : ""}`}
>
<td className={noGrowCellClass}>
<td className={`${cellClass} w-3`}>
<div className={"relative flex"}>
<FavoriteStarToggleButton
favorite={project.favorite}
<FavoriteToggleButton
project={project}
disabled={removed || loading}
onToggle={() =>
setProjectFavorite.mutate({
@ -134,7 +140,14 @@ export function ProjectRow({
className={"text-left select-text cursor-auto w-full"}
>
<p className="font-normal whitespace-pre">{project.name}</p>
<p className="font-normal opacity-50 text-sm whitespace-pre compact:hidden">
</TooltipTriggerIfValid>
<TooltipContent>{project.name}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTriggerIfValid
className={"text-left select-text cursor-auto w-full"}
>
<p className="font-normal opacity-50 text-sm whitespace-pre">
{project.path}
</p>
</TooltipTriggerIfValid>
@ -151,7 +164,7 @@ export function ProjectRow({
</TooltipPortal>
</Tooltip>
</td>
<td className={noGrowCellClass}>
<td className={`${cellClass} w-[8em] min-w-[8em]`}>
<div className="flex flex-row gap-2">
<div className="flex items-center">
{projectTypeKind === "avatars" ? (
@ -175,22 +188,6 @@ export function ProjectRow({
<td className={noGrowCellClass}>
<p className="font-normal">{project.unity}</p>
</td>
<td className={noGrowCellClass}>
<Tooltip>
<TooltipTrigger>
<time dateTime={createdAt.toISOString()}>
<time className="font-normal">
{dayToString(project.created_at)}
</time>
</time>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
{dateToString(project.created_at)}
</TooltipContent>
</TooltipPortal>
</Tooltip>
</td>
<td className={noGrowCellClass}>
<Tooltip>
<TooltipTrigger>
@ -208,7 +205,7 @@ export function ProjectRow({
</Tooltip>
</td>
<td className={noGrowCellClass}>
<div className="flex flex-row gap-2 max-w-min items-center">
<div className="flex flex-row gap-2 max-w-min">
<ButtonDisabledIfInvalid asChild>
<OpenUnityButton
projectPath={project.path}
@ -275,9 +272,11 @@ export function ProjectRow({
);
}
export function ManageOrMigrateButton({ project }: { project: TauriProject }) {
const navigate = useNavigate();
export function ManageOrMigrateButton({
project,
}: {
project: TauriProject;
}) {
if (compareUnityVersionString(project.unity, "2018.0.0f0") < 0) {
// No UPM is supported in unity 2017 or older
return (
@ -294,6 +293,7 @@ export function ManageOrMigrateButton({ project }: { project: TauriProject }) {
);
}
const navigate = useNavigate();
switch (project.project_type) {
case "LegacySdk2":
return (
@ -415,9 +415,9 @@ function ConfirmVpmMigrationDialog({
return (
<div className={"contents whitespace-normal"}>
<DialogTitle>{tc("projects:dialog:vpm migrate header")}</DialogTitle>
<div>
<DialogDescription>
<p>{tc("projects:dialog:vpm migrate description")}</p>
</div>
</DialogDescription>
<DialogFooter className={"gap-1"}>
<Button onClick={() => dialog.close(null)}>
{tc("general:button:cancel")}
@ -440,9 +440,9 @@ function VpmMigrationUpdating() {
return (
<div className={"contents whitespace-normal"}>
<DialogTitle>{tc("projects:dialog:vpm migrate header")}</DialogTitle>
<div>
<DialogDescription>
<p>{tc("projects:migrating...")}</p>
</div>
</DialogDescription>
</div>
);
}
@ -517,18 +517,49 @@ export const TooltipTriggerIfValid = ({
}
};
export function FavoriteToggleButton({
project,
disabled,
onToggle,
className,
}: {
project: { favorite: boolean };
disabled?: boolean;
onToggle: () => void;
className?: string;
}) {
if (disabled) return null;
return (
<Star
strokeWidth={project.favorite ? 1.5 : 3}
className={cn(
"size-4 transition-colors cursor-pointer",
project.favorite ? "text-foreground" : "text-foreground/30",
!project.favorite && "opacity-0 group-hover:opacity-100",
"hover:text-foreground",
className,
)}
fill={project.favorite ? "currentColor" : "none"}
onClick={() => {
if (!disabled) {
onToggle();
}
}}
/>
);
}
export function getProjectDisplayInfo(project: TauriProject) {
const projectTypeKind = ProjectDisplayType[project.project_type] ?? "unknown";
const displayType = tc(`projects:type:${projectTypeKind}`);
const isLegacy = LegacyProjectTypes.includes(project.project_type);
const createdAt = new Date(project.created_at);
const lastModified = new Date(project.last_modified);
return {
projectTypeKind,
displayType,
isLegacy,
createdAt,
lastModified,
};
}

View file

@ -1,7 +1,4 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { ArrowDown, ArrowUp } from "lucide-react";
import { useMemo } from "react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
@ -15,11 +12,14 @@ import {
import type { TauriProject } from "@/lib/bindings";
import { commands } from "@/lib/bindings";
import { tc } from "@/lib/i18n";
import { useQuery } from "@tanstack/react-query";
import { ArrowDown, ArrowUp } from "lucide-react";
import { useMemo } from "react";
import { ProjectGridItem } from "./-project-grid-item";
import {
isSorting,
type sortings,
sortSearchProjects,
type sortings,
useSetProjectSortingMutation,
} from "./-projects-list-card";
@ -30,7 +30,6 @@ const sortingOptions: { key: SimpleSorting; label: string }[] = [
{ key: "name", label: "general:name" },
{ key: "type", label: "projects:type" },
{ key: "unity", label: "projects:unity" },
{ key: "createdAt", label: "general:created at" },
{ key: "lastModified", label: "general:last modified" },
];
@ -78,27 +77,29 @@ export function ProjectsGridCard({
return (
<div className="flex flex-col h-full w-full overflow-hidden">
<Card className="flex items-center mb-3 flex-wrap p-2 gap-2 compact:p-1 compact:gap-1">
<p className="grow-0 whitespace-pre pl-2 leading-tight">
{tc("projects:sort by")}
</p>
<Select
value={currentKey}
onValueChange={(value) =>
handleChangeSortingKey(value as SimpleSorting)
}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{sortingOptions.map((option) => (
<SelectItem key={option.key} value={option.key}>
{tc(option.label)}
</SelectItem>
))}
</SelectContent>
</Select>
<Card className="flex items-center mb-3 flex-wrap">
<div className="flex items-center gap-1 m-2 ml-4">
<p className="grow-0 whitespace-pre mb-0 leading-tight">
{tc("projects:sort by")}
</p>
<Select
value={currentKey}
onValueChange={(value) =>
handleChangeSortingKey(value as SimpleSorting)
}
>
<SelectTrigger className="w-40">
<SelectValue />
</SelectTrigger>
<SelectContent>
{sortingOptions.map((option) => (
<SelectItem key={option.key} value={option.key}>
{tc(option.label)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button variant="ghost" size="icon" onClick={toggleOrder}>
{isReversed ? (
@ -113,10 +114,7 @@ export function ProjectsGridCard({
className="h-full w-full vrc-get-scrollable-card rounded-l-xl"
scrollBarClassName="bg-background rounded-full border-l-0 p-[1.5px]"
>
<div
className="grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-3 gap-3 overflow-x-hidden mr-4
compact:grid-cols-2 compact:lg:grid-cols-3 compact:2xl:grid-cols-4 compact:gap-1.5"
>
<div className="grid grid-cols-1 md:grid-cols-2 2xl:grid-cols-3 gap-3 overflow-x-hidden mr-4">
{projectsShown.map((project) => (
<ProjectGridItem
key={project.path}

View file

@ -1,7 +1,4 @@
"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronDown, ChevronsUpDown, ChevronUp, Star } from "lucide-react";
import { useMemo } from "react";
import { ScrollableCardTable } from "@/components/ScrollableCardTable";
import { assertNever } from "@/lib/assert-never";
import type { TauriProject, TauriProjectType } from "@/lib/bindings";
@ -9,15 +6,12 @@ import { commands } from "@/lib/bindings";
import { tc } from "@/lib/i18n";
import { toastThrownError } from "@/lib/toast";
import { compareUnityVersionString } from "@/lib/version";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronDown, ChevronUp, ChevronsUpDown, Star } from "lucide-react";
import { useMemo } from "react";
import { ProjectRow } from "./-project-row";
export const sortings = [
"createdAt",
"lastModified",
"name",
"unity",
"type",
] as const;
export const sortings = ["lastModified", "name", "unity", "type"] as const;
type SimpleSorting = (typeof sortings)[number];
type Sorting = SimpleSorting | `${SimpleSorting}Reversed`;
@ -164,18 +158,6 @@ export function ProjectsTableCard({
</small>
</button>
</th>
<th className={`${thClass} ${headerBg("createdAt")}`}>
<button
type="button"
className={"flex w-full project-table-button"}
onClick={() => setSorting("createdAt")}
>
{icon("createdAt")}
<small className="font-normal leading-none">
{tc("general:created at")}
</small>
</button>
</th>
<th className={`${thClass} ${headerBg("lastModified")}`}>
<button
type="button"
@ -212,12 +194,6 @@ export function sortSearchProjects(
searched.sort((a, b) => b.last_modified - a.last_modified);
switch (sorting) {
case "createdAt":
searched.sort((a, b) => b.created_at - a.created_at);
break;
case "createdAtReversed":
searched.sort((a, b) => a.created_at - b.created_at);
break;
case "lastModified":
searched.sort((a, b) => b.last_modified - a.last_modified);
break;

View file

@ -1,19 +1,10 @@
"use client";
import {
queryOptions,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { ChevronDown, LayoutGrid, LayoutList, RefreshCw } from "lucide-react";
import { useRef, useState } from "react";
import Loading from "@/app/-loading";
import { createProject } from "@/app/_main/projects/-create-project";
import { ProjectsGridCard } from "@/app/_main/projects/-projects-grid-card";
import Loading from "@/app/-loading";
import { HNavBar, HNavBarText, VStack } from "@/components/layout";
import { SearchBox } from "@/components/SearchBox";
import { HNavBar, VStack } from "@/components/layout";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import {
@ -33,6 +24,15 @@ import { isFindKey, useDocumentEvent } from "@/lib/events";
import { useProjectUpdateInProgress } from "@/lib/global-events";
import { tc, tt } from "@/lib/i18n";
import { toastError, toastSuccess, toastThrownError } from "@/lib/toast";
import {
queryOptions,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { ChevronDown, LayoutGrid, LayoutList, RefreshCw } from "lucide-react";
import { useRef, useState } from "react";
import { ProjectsTableCard } from "./-projects-list-card";
export const Route = createFileRoute("/_main/projects/")({
@ -188,14 +188,15 @@ function ProjectViewHeader({
return (
<HNavBar
className="shrink-0"
className={"shrink-0"}
leading={
<>
<HNavBarText>{tc("projects")}</HNavBarText>
<p className="cursor-pointer font-bold grow-0 whitespace-pre mb-0 leading-tight">
{tc("projects")}
</p>
<Tooltip>
<TooltipTrigger asChild>
<Button
className={"compact:h-10 compact:w-10"}
variant={"ghost"}
size={"icon"}
onClick={() =>
@ -214,14 +215,13 @@ function ProjectViewHeader({
</Tooltip>
<SearchBox
className={"w-max grow compact:h-10"}
className={"w-max grow"}
value={search}
onChange={(e) => setSearch(e.target.value)}
ref={searchRef}
/>
<Button
className={"compact:h-10"}
variant={"ghost"}
onClick={() => {
if (viewMode === "List") {
@ -254,15 +254,12 @@ function ProjectViewHeader({
<DropdownMenu>
<div className={"flex divide-x"}>
<Button
className={"rounded-r-none pl-4 pr-3 compact:h-10"}
className={"rounded-r-none pl-4 pr-3"}
onClick={startCreateProject}
>
{tc("projects:create new project")}
</Button>
<DropdownMenuTrigger
asChild
className={"rounded-l-none pl-2 pr-2 compact:h-10"}
>
<DropdownMenuTrigger asChild className={"rounded-l-none pl-2 pr-2"}>
<Button>
<ChevronDown className={"w-4 h-4"} />
</Button>

View file

@ -39,12 +39,11 @@ export interface PackageRowInfo {
infoSource: TauriVersion;
displayName: string;
description: string;
keywords: string[];
aliases: string[];
unityCompatible: Map<string, TauriPackage>;
unityIncompatible: Map<string, TauriPackage>;
sources: Set<string>;
isThereSource: boolean; // this will be true even if all sources are hidden
visibleSources: Set<string>;
installed: null | {
version: TauriVersion;
yanked: boolean;
@ -93,9 +92,7 @@ export function combinePackagesAndProjectDetails(
const yankedVersions = new Set<`${string}:${string}`>();
const knownPackages = new Set<string>();
const packagesPerRepository = new Map<string, TauriPackage[]>();
const hiddenPackagesPerRepository = new Map<string, TauriPackage[]>();
const userPackages: TauriPackage[] = [];
const hiddenUserPackages: TauriPackage[] = [];
for (const pkg of packages) {
if (!showPrereleasePackages && pkg.version.pre) continue;
@ -110,19 +107,13 @@ export function combinePackagesAndProjectDetails(
let packages: TauriPackage[];
// check the repository is visible
if (pkg.source === "LocalUser") {
if (hideLocalUserPackages) {
packages = hiddenUserPackages;
} else {
packages = userPackages;
}
if (hideLocalUserPackages) continue;
packages = userPackages;
} else if ("Remote" in pkg.source) {
if (hiddenRepositoriesSet.has(pkg.source.Remote.id)) {
packages = hiddenPackagesPerRepository.get(pkg.source.Remote.id) ?? [];
hiddenPackagesPerRepository.set(pkg.source.Remote.id, packages);
} else {
packages = packagesPerRepository.get(pkg.source.Remote.id) ?? [];
packagesPerRepository.set(pkg.source.Remote.id, packages);
}
if (hiddenRepositoriesSet.has(pkg.source.Remote.id)) continue;
packages = packagesPerRepository.get(pkg.source.Remote.id) ?? [];
packagesPerRepository.set(pkg.source.Remote.id, packages);
} else {
assertNever(pkg.source);
}
@ -141,13 +132,12 @@ export function combinePackagesAndProjectDetails(
id: pkg.name,
displayName: pkg.display_name ?? pkg.name,
description: pkg.description ?? "",
keywords: pkg.keywords,
aliases: pkg.aliases,
infoSource: pkg.version,
unityCompatible: new Map(),
unityIncompatible: new Map(),
sources: new Set(),
isThereSource: false,
visibleSources: new Set(),
installed: null,
latest: { status: "none" },
stableLatest: { status: "none" },
@ -177,7 +167,7 @@ export function combinePackagesAndProjectDetails(
packageRowInfo.displayName = pkg.display_name ?? pkg.name;
packageRowInfo.description =
pkg.description || packageRowInfo.description;
packageRowInfo.keywords = pkg.keywords;
packageRowInfo.aliases = pkg.aliases;
}
if (project == null || isUnityCompatible(pkg, project.unity)) {
@ -188,14 +178,8 @@ export function combinePackagesAndProjectDetails(
if (pkg.source === "LocalUser") {
packageRowInfo.sources.add("User");
if (!hideLocalUserPackages) {
packageRowInfo.visibleSources.add("User");
}
} else if ("Remote" in pkg.source) {
packageRowInfo.sources.add(pkg.source.Remote.display_name);
if (!hiddenRepositoriesSet.has(pkg.source.Remote.id)) {
packageRowInfo.visibleSources.add(pkg.source.Remote.display_name);
}
}
}
@ -203,13 +187,6 @@ export function combinePackagesAndProjectDetails(
packagesPerRepository.get("com.vrchat.repos.official")?.forEach(addPackage);
packagesPerRepository.get("com.vrchat.repos.curated")?.forEach(addPackage);
userPackages.forEach(addPackage);
hiddenUserPackages.forEach((pkg) => {
const packageRowInfo = getRowInfo(pkg);
packageRowInfo.isThereSource = true;
if (pkg.source === "LocalUser") {
packageRowInfo.sources.add("User");
}
});
packagesPerRepository.delete("com.vrchat.repos.official");
packagesPerRepository.delete("com.vrchat.repos.curated");
@ -224,17 +201,6 @@ export function combinePackagesAndProjectDetails(
packages.forEach(addPackage);
}
// process hidden repositories - only add to sources, not to version calculations
for (const packages of hiddenPackagesPerRepository.values()) {
packages.forEach((pkg) => {
const packageRowInfo = getRowInfo(pkg);
packageRowInfo.isThereSource = true;
if (pkg.source !== "LocalUser") {
packageRowInfo.sources.add(pkg.source.Remote.display_name);
}
});
}
// sort versions
for (const value of packagesTable.values()) {
value.unityCompatible = new Map(
@ -326,14 +292,14 @@ export function combinePackagesAndProjectDetails(
// if installed, use the installed version to get the display name
packageRowInfo.displayName = pkg.display_name ?? pkg.name;
packageRowInfo.keywords = [...pkg.keywords, ...packageRowInfo.keywords];
packageRowInfo.aliases = [...pkg.aliases, ...packageRowInfo.aliases];
packageRowInfo.installed = {
version: pkg.version,
yanked:
pkg.is_yanked ||
yankedVersions.has(`${pkg.name}:${toVersionString(pkg.version)}`),
};
packageRowInfo.isThereSource = true;
packageRowInfo.isThereSource = knownPackages.has(pkg.name);
// if we have the latest version, check if it's upgradable
if (packageRowInfo.latest.status !== "none") {

View file

@ -1,13 +1,14 @@
import { useMutation } from "@tanstack/react-query";
import type { NavigateFn } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { VStack } from "@/components/layout";
import { Button } from "@/components/ui/button";
import { DialogFooter, DialogTitle } from "@/components/ui/dialog";
import {
DialogDescription,
DialogFooter,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Progress } from "@/components/ui/progress";
import { assertNever } from "@/lib/assert-never";
import { commands, type TauriCopyProjectProgress } from "@/lib/bindings";
import { type TauriCopyProjectProgress, commands } from "@/lib/bindings";
import { callAsyncCommand } from "@/lib/call-async-command";
import { type DialogContext, showDialog } from "@/lib/dialog";
import { tc, tt } from "@/lib/i18n";
@ -18,6 +19,11 @@ import {
} from "@/lib/project-name-check";
import { queryClient } from "@/lib/query-client";
import { toastError, toastSuccess, toastThrownError } from "@/lib/toast";
import { useMutation } from "@tanstack/react-query";
import type { NavigateFn } from "@tanstack/react-router";
import type React from "react";
import { useEffect } from "react";
import { useState } from "react";
export async function copyProject(existingPath: string, navigate?: NavigateFn) {
using dialog = showDialog();
@ -52,6 +58,31 @@ export async function copyProject(existingPath: string, navigate?: NavigateFn) {
});
}
function DialogBase({
children,
close,
createProject,
}: {
children: React.ReactNode;
close?: () => void;
createProject?: () => void;
}) {
return (
<>
<DialogTitle>{tc("projects:dialog:copy project")}</DialogTitle>
<DialogDescription>{children}</DialogDescription>
<DialogFooter className={"gap-2"}>
<Button onClick={close} disabled={!close}>
{tc("general:button:cancel")}
</Button>
<Button onClick={createProject} disabled={!createProject}>
{tc("projects:button:create")}
</Button>
</DialogFooter>
</>
);
}
function CopyProjectNameDialog({
dialog,
projectPath,
@ -109,7 +140,7 @@ function CopyProjectNameDialog({
<DialogTitle>
{tc("projects:dialog:copy project", { name: oldName })}
</DialogTitle>
<div>
<DialogDescription>
<VStack>
<Input
value={projectNameRaw}
@ -145,7 +176,7 @@ function CopyProjectNameDialog({
projectNameCheckState={projectNameCheckState}
/>
</VStack>
</div>
</DialogDescription>
<DialogFooter className={"gap-2"}>
<Button onClick={() => dialog.close(null)}>
{tc("general:button:cancel")}
@ -195,7 +226,7 @@ export function CopyingDialog({
<DialogTitle>
{tc("projects:dialog:copy project", { name: oldName })}
</DialogTitle>
<div>
<DialogDescription>
<p>{tc("projects:dialog:copying...")}</p>
<p>
{tc("projects:dialog:proceed k/n", {
@ -205,7 +236,7 @@ export function CopyingDialog({
</p>
<Progress value={progress.proceed} max={progress.total} />
<p>{tc("projects:do not close")}</p>
</div>
</DialogDescription>
<DialogFooter className={"gap-2"}>
<Button disabled>{tc("general:button:cancel")}</Button>
</DialogFooter>

View file

@ -1,28 +1,5 @@
// noinspection ExceptionCaughtLocallyJS
import {
queryOptions,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import {
ChevronDown,
ChevronRight,
CircleArrowUp,
CircleMinus,
CirclePlus,
Ellipsis,
RefreshCw,
} from "lucide-react";
import type React from "react";
import {
memo,
useCallback,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import { applyChangesMutation } from "@/app/_main/projects/manage/-use-package-change";
import { Route } from "@/app/_main/projects/manage/index";
import { ExternalLink } from "@/components/ExternalLink";
@ -30,7 +7,6 @@ import { ScrollableCardTable } from "@/components/ScrollableCardTable";
import { SearchBox } from "@/components/SearchBox";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { DialogFooter, DialogTitle } from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
@ -58,12 +34,27 @@ import {
import { assertNever } from "@/lib/assert-never";
import type { TauriPackage, TauriRepositoriesInfo } from "@/lib/bindings";
import { commands } from "@/lib/bindings";
import { type DialogContext, openSingleDialog } from "@/lib/dialog";
import { isFindKey, useDocumentEvent } from "@/lib/events";
import { usePackageUpdateInProgress } from "@/lib/global-events";
import { tc, tt } from "@/lib/i18n";
import { toastThrownError } from "@/lib/toast";
import { toVersionString } from "@/lib/version";
import {
queryOptions,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
import {
CircleArrowUp,
CircleMinus,
CirclePlus,
Ellipsis,
RefreshCw,
} from "lucide-react";
import type React from "react";
import { useLayoutEffect } from "react";
import { useRef } from "react";
import { memo, useCallback, useEffect, useMemo, useState } from "react";
import type {
PackageLatestInfo,
PackageRowInfo,
@ -90,23 +81,9 @@ export const PackageListCard = memo(function PackageListCard({
onRefresh: () => void;
}) {
const [search, setSearch] = useState("");
const [bulkUpdatePackageIdsRaw, setBulkUpdatePackageIds] = useState<string[]>(
const [bulkUpdatePackageIds, setBulkUpdatePackageIds] = useState<string[]>(
[],
);
const [showHiddenPackages, setShowHiddenPackages] = useState(false);
const bulkUpdatePackageIds = useMemo(() => {
const packageIds = new Set(packageRowsData.map((p) => p.id));
return bulkUpdatePackageIdsRaw.filter((pkgId) => packageIds.has(pkgId));
}, [packageRowsData, bulkUpdatePackageIdsRaw]);
useDocumentEvent(
"post-package-changes",
() => setBulkUpdatePackageIds([]),
[],
);
const bulkUpdateMode = useMemo(() => {
const packageRowByPackageId = new Map(
packageRowsData.map((row) => [row.id, row]),
@ -129,7 +106,7 @@ export const PackageListCard = memo(function PackageListCard({
(row) =>
row.displayName.toLowerCase().includes(searchLower) ||
row.id.toLowerCase().includes(searchLower) ||
row.keywords.some((alias) =>
row.aliases.some((alias) =>
alias.toLowerCase().includes(searchLower),
),
)
@ -137,27 +114,19 @@ export const PackageListCard = memo(function PackageListCard({
);
}, [packageRowsData, search]);
const hiddenPackages = useMemo(() => {
return packageRowsData.filter(
(pkg) =>
pkg.visibleSources.size === 0 && pkg.isThereSource && !pkg.installed,
);
}, [packageRowsData]);
const visibleHiddenPackagesCount = useMemo(() => {
return hiddenPackages.filter((pkg) => filteredPackageIds.has(pkg.id))
.length;
}, [hiddenPackages, filteredPackageIds]);
const toggleShowHiddenPackages = useCallback(() => {
setShowHiddenPackages((prev) => !prev);
}, []);
const hiddenUserRepositories = useMemo(
() => new Set(repositoriesInfo?.hidden_user_repositories ?? []),
[repositoriesInfo],
);
useEffect(() => {
setBulkUpdatePackageIds((ids) => {
if (ids.length === 0) return [];
const packageIds = new Set(packageRowsData.map((p) => p.id));
return ids.filter((x) => packageIds.has(x));
});
}, [packageRowsData]);
const addBulkUpdatePackage = useCallback((row: PackageRowInfo) => {
setBulkUpdatePackageIds((prev) => {
return prev.some((id) => id === row.id) ? prev : [...prev, row.id];
@ -199,7 +168,7 @@ export const PackageListCard = memo(function PackageListCard({
return (
<Card className="grow shrink flex shadow-none w-full">
<CardContent className="w-full p-2 flex flex-col gap-2 compact:p-1 compact:gap-1.5">
<CardContent className="w-full p-2 flex flex-col gap-2">
<ManagePackagesHeading
packageRowsData={packageRowsData}
hiddenUserRepositories={hiddenUserRepositories}
@ -215,7 +184,7 @@ export const PackageListCard = memo(function PackageListCard({
cancel={() => setBulkUpdatePackageIds([])}
/>
<ScrollableCardTable
className={"h-full rounded-md"}
className={"h-full"}
ref={scrollTableOuterRef}
viewportRef={scrollTableScrollAreaRef}
>
@ -231,7 +200,7 @@ export const PackageListCard = memo(function PackageListCard({
// biome-ignore lint/suspicious/noArrayIndexKey: static array
key={index}
className={
"sticky top-0 z-10 border-b border-primary bg-secondary text-secondary-foreground px-2.5 py-1.5"
"sticky top-0 z-10 border-b border-primary bg-secondary text-secondary-foreground p-2.5"
}
>
<small className="font-normal leading-none">{tc(head)}</small>
@ -239,85 +208,32 @@ export const PackageListCard = memo(function PackageListCard({
))}
<th
className={
"sticky top-0 z-10 border-b border-primary bg-secondary text-secondary-foreground px-2.5 py-1.5"
"sticky top-0 z-10 border-b border-primary bg-secondary text-secondary-foreground p-2.5"
}
/>
</tr>
</thead>
<tbody>
{packageRowsData.map((row) => {
if (
row.visibleSources.size === 0 &&
row.isThereSource &&
!row.installed
)
return null;
return (
<tr
className="even:bg-secondary/30 anchor-none"
hidden={!filteredPackageIds.has(row.id)}
key={row.id}
>
<PackageRow
pkg={row}
bulkUpdateSelected={bulkUpdatePackageIds.some(
(id) => id === row.id,
)}
bulkUpdateAvailable={canBulkUpdate(
bulkUpdateMode,
bulkUpdateModeForPackage(row),
)}
addBulkUpdatePackage={addBulkUpdatePackage}
removeBulkUpdatePackage={removeBulkUpdatePackage}
/>
</tr>
);
})}
{/* Hidden packages section */}
{hiddenPackages.length > 0 && (
<>
<tr
className="bg-secondary/50 hover:bg-secondary/70 cursor-pointer"
onClick={toggleShowHiddenPackages}
>
<td className="p-3.5 compact:py-1 w-1">
{showHiddenPackages ? (
<ChevronDown className="w-5 h-5" />
) : (
<ChevronRight className="w-5 h-5" />
)}
</td>
<td
colSpan={TABLE_HEAD.length + 1}
className="p-3.5 compact:py-1 font-medium text-sm text-muted-foreground"
>
{tc("projects:manage:hidden packages")} (
{visibleHiddenPackagesCount})
</td>
</tr>
{showHiddenPackages &&
hiddenPackages.map((row) => (
<tr
className="even:bg-secondary/30 anchor-none"
hidden={!filteredPackageIds.has(row.id)}
key={row.id}
>
<PackageRow
pkg={row}
bulkUpdateSelected={bulkUpdatePackageIds.some(
(id) => id === row.id,
)}
bulkUpdateAvailable={canBulkUpdate(
bulkUpdateMode,
bulkUpdateModeForPackage(row),
)}
addBulkUpdatePackage={addBulkUpdatePackage}
removeBulkUpdatePackage={removeBulkUpdatePackage}
/>
</tr>
))}
</>
)}
{packageRowsData.map((row) => (
<tr
className="even:bg-secondary/30 anchor-none"
hidden={!filteredPackageIds.has(row.id)}
key={row.id}
>
<PackageRow
pkg={row}
bulkUpdateSelected={bulkUpdatePackageIds.some(
(id) => id === row.id,
)}
bulkUpdateAvailable={canBulkUpdate(
bulkUpdateMode,
bulkUpdateModeForPackage(row),
)}
addBulkUpdatePackage={addBulkUpdatePackage}
removeBulkUpdatePackage={removeBulkUpdatePackage}
/>
</tr>
))}
</tbody>
</ScrollableCardTable>
</CardContent>
@ -326,29 +242,6 @@ export const PackageListCard = memo(function PackageListCard({
);
});
function ShowPrereleaseConfirmDialog({
dialog,
}: {
dialog: DialogContext<boolean>;
}) {
return (
<>
<DialogTitle>
{tc("settings:dialog:show prerelease packages")}
</DialogTitle>
<div>{tc("settings:dialog:show prerelease packages description")}</div>
<DialogFooter>
<Button onClick={() => dialog.close(false)}>
{tc("general:button:cancel")}
</Button>
<Button variant="warning" onClick={() => dialog.close(true)}>
{tc("settings:dialog:enable show prerelease packages")}
</Button>
</DialogFooter>
</>
);
}
function ManagePackagesHeading({
packageRowsData,
hiddenUserRepositories,
@ -443,11 +336,9 @@ function ManagePackagesHeading({
return (
<div
className={
"flex flex-wrap shrink-0 grow-0 flex-row gap-2 items-center gap-y-1"
}
className={"flex flex-wrap shrink-0 grow-0 flex-row gap-2 items-center"}
>
<p className="cursor-pointer font-bold grow-0 shrink-0 pl-2">
<p className="cursor-pointer font-bold py-1.5 grow-0 shrink-0 pl-2">
{tc("projects:manage:manage packages")}
</p>
@ -457,7 +348,7 @@ function ManagePackagesHeading({
variant={"ghost"}
size={"icon"}
onClick={onRefresh}
className="shrink-0"
className={"shrink-0"}
disabled={isLoading || backgroundLoading}
>
{isLoading || backgroundLoading ? (
@ -481,7 +372,7 @@ function ManagePackagesHeading({
{upgradableToLatest && (
<Button
className="shrink-0"
className={"shrink-0"}
onClick={() => onUpgradeAllRequest(false)}
disabled={isLoading}
variant={"success"}
@ -493,7 +384,7 @@ function ManagePackagesHeading({
{/* show this button only if some packages are upgradable to prerelease and there is different stable */}
{upgradableToStable && (
<Button
className="shrink-0"
className={"shrink-0"}
onClick={() => onUpgradeAllRequest(true)}
disabled={isLoading}
variant={"success"}
@ -564,21 +455,9 @@ function ManagePackagesHeading({
checked={repositoriesInfo?.show_prerelease_packages}
onClick={(e) => {
e.preventDefault();
const newValue = !repositoriesInfo?.show_prerelease_packages;
if (newValue) {
void openSingleDialog(ShowPrereleaseConfirmDialog, {})
.then((confirmed) => {
if (confirmed) {
setShowPrereleasePackages.mutate(true);
}
})
.catch((e) => {
console.error(e);
toastThrownError(e);
});
} else {
setShowPrereleasePackages.mutate(false);
}
setShowPrereleasePackages.mutate(
!repositoriesInfo?.show_prerelease_packages,
);
}}
>
{tc("settings:show prerelease")}
@ -651,6 +530,13 @@ function bulkUpdateModeForPackage(pkg: PackageRowInfo): PackageBulkUpdateMode {
};
}
function hasAnyUpdate(pkg: PackageBulkUpdateMode): boolean {
for (const kind of possibleUpdateKind) {
if (pkg[kind]) return true;
}
return false;
}
function canBulkUpdate(
bulkUpdateMode: BulkUpdateMode,
possibleUpdate: PackageBulkUpdateMode,
@ -673,6 +559,8 @@ function BulkUpdateCard({
packageRowsData: PackageRowInfo[];
cancel?: () => void;
}) {
if (!bulkUpdateMode.hasPackages) return null;
const count = bulkUpdatePackageIds.length;
const { projectPath } = Route.useSearch();
const packageChange = useMutation(applyChangesMutation(projectPath));
@ -729,11 +617,10 @@ function BulkUpdateCard({
});
};
if (!bulkUpdateMode.hasPackages) return null;
return (
<Card
className={
"shrink-0 p-2 compact:p-1 flex flex-row gap-2 compact:gap-1 bg-secondary text-secondary-foreground flex-wrap"
"shrink-0 p-2 flex flex-row gap-2 bg-secondary text-secondary-foreground flex-wrap"
}
>
{bulkUpdateMode.canInstallOrUpgrade && (
@ -763,7 +650,7 @@ function BulkUpdateCard({
{tc("projects:manage:button:uninstall selected")}
</ButtonDisabledIfLoading>
)}
<ButtonDisabledIfLoading onClick={cancel} variant={"warning"}>
<ButtonDisabledIfLoading onClick={cancel}>
{tc("projects:manage:button:clear selection")}
{" ("}
{tc("projects:manage:n packages selected", { count })}
@ -905,7 +792,7 @@ const PackageRow = memo(function PackageRow({
addBulkUpdatePackage: (pkg: PackageRowInfo) => void;
removeBulkUpdatePackage: (pkg: PackageRowInfo) => void;
}) {
const cellClass = "p-3.5 compact:py-1";
const cellClass = "p-2.5";
const noGrowCellClass = `${cellClass} w-1`;
const versionNames = [...pkg.unityCompatible.keys()];
const latestVersion: string | undefined = versionNames[0];
@ -954,26 +841,26 @@ const PackageRow = memo(function PackageRow({
return (
<>
<td className={`${cellClass} w-1 compact:px-2`}>
<div className={"flex items-center justify-center aspect-square"}>
<CheckboxDisabledIfLoading
checked={bulkUpdateSelected}
onCheckedChange={onClickBulkUpdate}
disabled={!bulkUpdateAvailable}
className="hover:before:content-none"
/>
</div>
<td className={`${cellClass} w-1`}>
<CheckboxDisabledIfLoading
checked={bulkUpdateSelected}
onCheckedChange={onClickBulkUpdate}
disabled={!bulkUpdateAvailable}
className="hover:before:content-none"
/>
</td>
<td className={`${cellClass} overflow-hidden max-w-80 text-ellipsis`}>
<Tooltip>
<Tooltip
open={
pkg.description ? undefined /* auto */ : false /* disable tooltip */
}
>
<TooltipTrigger asChild>
<div
className={`flex flex-col ${pkg.installed ? "" : "opacity-50"}`}
>
<p className="font-normal">{pkg.displayName}</p>
<p className="font-normal opacity-50 text-sm compact:hidden">
{pkg.id}
</p>
<p className="font-normal opacity-50 text-sm">{pkg.id}</p>
</div>
</TooltipTrigger>
<TooltipContent className={"max-w-[80dvw]"}>
@ -982,7 +869,6 @@ const PackageRow = memo(function PackageRow({
>
{pkg.description}
</p>
<p className="font-normal opacity-50 text-sm">{pkg.id}</p>
</TooltipContent>
</Tooltip>
</td>
@ -993,29 +879,27 @@ const PackageRow = memo(function PackageRow({
<LatestPackageInfo info={pkg.latest} />
</td>
<td className={`${noGrowCellClass} max-w-32 overflow-hidden`}>
{pkg.visibleSources.size === 0 ? (
{pkg.sources.size === 0 ? (
pkg.isThereSource ? (
<p>{tc("projects:manage:source not selected")}</p>
) : (
<p>{tc("projects:manage:none")}</p>
)
) : pkg.visibleSources.size === 1 ? (
) : pkg.sources.size === 1 ? (
<Tooltip>
<TooltipTrigger>
<p className="overflow-hidden text-ellipsis">
{[...pkg.visibleSources][0]}
{[...pkg.sources][0]}
</p>
</TooltipTrigger>
<TooltipContent>{[...pkg.visibleSources][0]}</TooltipContent>
<TooltipContent>{[...pkg.sources][0]}</TooltipContent>
</Tooltip>
) : (
<Tooltip>
<TooltipTrigger>
<p>{tc("projects:manage:multiple sources")}</p>
</TooltipTrigger>
<TooltipContent>
{[...pkg.visibleSources].join(", ")}
</TooltipContent>
<TooltipContent>{[...pkg.sources].join(", ")}</TooltipContent>
</Tooltip>
)}
</td>
@ -1052,24 +936,9 @@ const PackageRow = memo(function PackageRow({
</ButtonDisabledIfLoading>
</TooltipTrigger>
<TooltipContent>
{pkg.visibleSources.size === 0 && pkg.isThereSource ? (
<div className="flex flex-col gap-1">
<p>
{tc(
"projects:manage:tooltip:select repository to install",
)}
</p>
<p className="text-xs opacity-75">
{[...pkg.sources]
.filter((source) => !pkg.visibleSources.has(source))
.join(", ")}
</p>
</div>
) : !latestVersion ? (
tc("projects:manage:tooltip:incompatible with unity")
) : (
tc("projects:manage:tooltip:add package")
)}
{!latestVersion
? tc("projects:manage:tooltip:incompatible with unity")
: tc("projects:manage:tooltip:add package")}
</TooltipContent>
</Tooltip>
)}
@ -1154,7 +1023,7 @@ const PackageVersionSelector = memo(function PackageVersionSelector({
<PackageInstalledInfo pkg={pkg} />
</SelectValue>
</SelectTrigger>
<SelectContent className="max-h-[min(24rem,45vh)]">
<SelectContent>
{/* PackageVersionList is extremely heavy */}
{isOpen && (
<PackageVersionList
@ -1196,7 +1065,11 @@ function PackageVersionList({
);
}
function PackageInstalledInfo({ pkg }: { pkg: PackageRowInfo }) {
function PackageInstalledInfo({
pkg,
}: {
pkg: PackageRowInfo;
}) {
if (pkg.installed) {
const version = toVersionString(pkg.installed.version);
if (pkg.installed.yanked) {
@ -1215,7 +1088,11 @@ function PackageInstalledInfo({ pkg }: { pkg: PackageRowInfo }) {
}
}
function LatestPackageInfo({ info }: { info: PackageLatestInfo }) {
function LatestPackageInfo({
info,
}: {
info: PackageLatestInfo;
}) {
const { projectPath } = Route.useSearch();
const packageChange = useMutation(applyChangesMutation(projectPath));

View file

@ -1,7 +1,7 @@
import { type ComponentProps, createContext, useContext } from "react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { type ComponentProps, createContext, useContext } from "react";
interface PageContext {
isLoading: boolean;

View file

@ -1,8 +1,10 @@
import type { NavigateFn } from "@tanstack/react-router";
import React, { Fragment, useEffect, useState } from "react";
import { BackupProjectDialog } from "@/components/BackupProjectDialog";
import { Button } from "@/components/ui/button";
import { DialogFooter, DialogTitle } from "@/components/ui/dialog";
import {
DialogDescription,
DialogFooter,
DialogTitle,
} from "@/components/ui/dialog";
import { Progress } from "@/components/ui/progress";
import { UnitySelectorDialog } from "@/components/unity-selector-dialog";
import { assertNever } from "@/lib/assert-never";
@ -19,6 +21,8 @@ import { tc, tt } from "@/lib/i18n";
import { queryClient } from "@/lib/query-client";
import { toastError, toastSuccess, toastThrownError } from "@/lib/toast";
import { compareUnityVersionString, parseUnityVersion } from "@/lib/version";
import type { NavigateFn } from "@tanstack/react-router";
import React, { Fragment, useEffect, useState } from "react";
export async function unityVersionChange({
version: targetUnityVersion,
@ -202,14 +206,14 @@ function NoExactUnity2022Dialog({
return (
<>
<DialogTitle>{header}</DialogTitle>
<div>
<DialogDescription>
<p>
{tc(
"projects:manage:dialog:exact version unity not found for patch migration description",
{ unity: expectedVersion },
)}
</p>
</div>
</DialogDescription>
<DialogFooter className={"gap-2"}>
{installWithUnityHubLink && (
<Button
@ -238,11 +242,11 @@ function MigrationConfirmMigrationPatchDialog({
return (
<>
<DialogTitle>{header}</DialogTitle>
<div>
<DialogDescription>
<p className={"text-destructive"}>
{tc("projects:dialog:migrate unity2022 patch description", { unity })}
</p>
</div>
</DialogDescription>
<DialogFooter>
<Button onClick={() => dialog.close(null)} className="mr-1">
{tc("general:button:cancel")}
@ -265,9 +269,9 @@ function MigrationConfirmMigrationDialog({
return (
<>
<DialogTitle>{header}</DialogTitle>
<div>
<DialogDescription>
<p>{tc("projects:dialog:vpm migrate description")}</p>
</div>
</DialogDescription>
<DialogFooter className={"gap-1"}>
<Button onClick={() => dialog.close(null)}>
{tc("general:button:cancel")}
@ -326,9 +330,9 @@ function UnityVersionChange({
return (
<>
<DialogTitle>{header}</DialogTitle>
<div>
<DialogDescription>
<p className={"text-destructive"}>{mainMessage}</p>
</div>
</DialogDescription>
<DialogFooter>
<Button onClick={() => dialog.close(null)} className="mr-1">
{tc("general:button:cancel")}
@ -374,7 +378,7 @@ export function MigrationCopyingDialog({
return (
<>
<DialogTitle>{header}</DialogTitle>
<div>
<DialogDescription>
<p>{tc("projects:pre-migrate copying...")}</p>
<p>
{tc("projects:dialog:proceed k/n", {
@ -384,7 +388,7 @@ export function MigrationCopyingDialog({
</p>
<Progress value={progress.proceed} max={progress.total} />
<p>{tc("projects:do not close")}</p>
</div>
</DialogDescription>
</>
);
}
@ -393,10 +397,10 @@ function MigrationMigratingDialog({ header }: { header: React.ReactNode }) {
return (
<>
<DialogTitle>{header}</DialogTitle>
<div>
<DialogDescription>
<p>{tc("projects:migrating...")}</p>
<p>{tc("projects:do not close")}</p>
</div>
</DialogDescription>
</>
);
}
@ -560,7 +564,7 @@ function MigrationCallingUnityForMigrationDialog({
return (
<>
<DialogTitle>{header}</DialogTitle>
<div>
<DialogDescription>
<p>{tc("projects:manage:dialog:unity migrate finalizing...")}</p>
<p>{tc("projects:do not close")}</p>
{/* TODO: use ScrollArea (I failed to use it inside dialog) */}
@ -577,7 +581,7 @@ function MigrationCallingUnityForMigrationDialog({
))}
<div ref={ref} />
</pre>
</div>
</DialogDescription>
</>
);
}

View file

@ -1,8 +1,3 @@
import type { DefaultError } from "@tanstack/query-core";
import { queryOptions, type UseMutationOptions } from "@tanstack/react-query";
import { CircleAlert } from "lucide-react";
import type React from "react";
import { Fragment } from "react";
import { DelayedButton } from "@/components/DelayedButton";
import { ExternalLink } from "@/components/ExternalLink";
import { Button } from "@/components/ui/button";
@ -29,6 +24,11 @@ import { queryClient } from "@/lib/query-client";
import { toastInfo, toastSuccess, toastThrownError } from "@/lib/toast";
import { groupBy, keyComparator } from "@/lib/utils";
import { compareVersion, toVersionString } from "@/lib/version";
import type { DefaultError } from "@tanstack/query-core";
import { type UseMutationOptions, queryOptions } from "@tanstack/react-query";
import { CircleAlert } from "lucide-react";
import type React from "react";
import { Fragment } from "react";
export type RequestedOperation =
| {
@ -96,7 +96,6 @@ export function applyChangesMutation(projectPath: string) {
toastThrownError(e);
},
onSettled: async () => {
document.dispatchEvent(new Event("post-package-changes"));
await queryClient.invalidateQueries({
queryKey: ["projectDetails", projectPath],
});
@ -244,12 +243,6 @@ function showToast(requested: RequestedOperation) {
}
}
const TypographyItem = ({ children }: { children: React.ReactNode }) => (
<div className={"p-3"}>
<p className={"font-normal"}>{children}</p>
</div>
);
function ProjectChangesDialog({
changes,
existingPackages,
@ -267,6 +260,12 @@ function ProjectChangesDialog({
([_, c]) => c.unlocked_names,
);
const TypographyItem = ({ children }: { children: React.ReactNode }) => (
<div className={"p-3"}>
<p className={"font-normal"}>{children}</p>
</div>
);
const existingPackageMap = new Map(existingPackages ?? []);
const categorizedChanges = changes.package_changes.map(([pkgId, change]) =>
@ -616,7 +615,7 @@ function categorizeChange(
change: TauriPackageChange,
installedPackages: Map<string, TauriBasePackageInfo>,
): PackageChangeDisplayInformation {
if (change.InstallNew !== undefined) {
if ("InstallNew" in change) {
const name = change.InstallNew.display_name ?? change.InstallNew.name;
const installed = installedPackages.get(pkgId);
@ -761,6 +760,13 @@ function ChangelogButton({ url }: { url?: string | null }) {
return null;
}
function comparePackageChangeByName(
[aName]: [string, TauriPackageChange],
[bName]: [string, TauriPackageChange],
): number {
return aName.localeCompare(bName);
}
function MissingDependenciesDialog({
dependencies,
dialog,
@ -774,7 +780,7 @@ function MissingDependenciesDialog({
<CircleAlert className="size-6 inline" />{" "}
{tc("projects:manage:dialog:missing dependencies")}
</DialogTitle>
<div>
<DialogDescription>
<p className={"whitespace-normal"}>
{tc("projects:manage:dialog:missing dependencies description")}
</p>
@ -785,7 +791,7 @@ function MissingDependenciesDialog({
</li>
))}
</ul>
</div>
</DialogDescription>
<DialogFooter>
<Button onClick={() => dialog.close()}>
{tc("general:button:close")}

View file

@ -1,30 +1,17 @@
"use client";
import {
queryOptions,
type UseQueryResult,
useIsMutating,
useMutation,
useQueries,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
createFileRoute,
useNavigate,
useRouter,
} from "@tanstack/react-router";
import { ArrowLeft, ChevronDown } from "lucide-react";
import type React from "react";
import { Suspense, useMemo } from "react";
import { copyProject } from "@/app/_main/projects/manage/-copy-project";
import { BackupProjectDialog } from "@/components/BackupProjectDialog";
import { HNavBar, VStack } from "@/components/layout";
import { OpenUnityButton } from "@/components/OpenUnityButton";
import { RemoveProjectDialog } from "@/components/RemoveProjectDialog";
import { HNavBar, VStack } from "@/components/layout";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { DialogFooter, DialogTitle } from "@/components/ui/dialog";
import {
DialogDescription,
DialogFooter,
DialogTitle,
} from "@/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
@ -57,6 +44,23 @@ import { tc } from "@/lib/i18n";
import { nameFromPath } from "@/lib/os";
import { toastSuccess, toastThrownError } from "@/lib/toast";
import { compareUnityVersionString, parseUnityVersion } from "@/lib/version";
import {
type UseQueryResult,
queryOptions,
useIsMutating,
useMutation,
useQueries,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import {
createFileRoute,
useNavigate,
useRouter,
} from "@tanstack/react-router";
import { ArrowLeft, ChevronDown } from "lucide-react";
import type React from "react";
import { Suspense, useMemo } from "react";
import { combinePackagesAndProjectDetails } from "./-collect-package-row-info";
import { PackageListCard } from "./-package-list-card";
import { PageContextProvider } from "./-page-context";
@ -198,7 +202,7 @@ function PageBody() {
<PageContextProvider value={pageContext}>
<VStack>
<ProjectViewHeader
className="shrink-0"
className={"shrink-0"}
isLoading={isLoading}
detailsResult={detailsResult}
unityVersionsResult={unityVersionsResult}
@ -305,7 +309,7 @@ function UnityVersionSelector({
value={detailsResult.data?.unity_str ?? undefined}
onValueChange={requestChangeUnityVersion}
>
<SelectTrigger className={"compact:h-10"}>
<SelectTrigger>
{detailsResult.status === "success" ? (
(detailsResult.data.unity_str ?? "unknown")
) : (
@ -319,13 +323,17 @@ function UnityVersionSelector({
);
}
function SuggestResolveProjectCard({ disabled }: { disabled?: boolean }) {
function SuggestResolveProjectCard({
disabled,
}: {
disabled?: boolean;
}) {
const { projectPath } = Route.useSearch();
const packageChange = useMutation(applyChangesMutation(projectPath));
return (
<Card className={"shrink-0 p-2 flex flex-row items-center compact:p-1"}>
<p className="cursor-pointer py-1.5 font-bold grow-0 shrink overflow-hidden whitespace-normal text-sm pl-2">
<Card className={"shrink-0 p-2 flex flex-row items-center"}>
<p className="cursor-pointer py-1.5 font-bold grow-0 shrink overflow-hidden whitespace-normal text-sm">
{tc("projects:manage:suggest resolve")}
</p>
<div className={"grow shrink-0 w-2"} />
@ -427,7 +435,7 @@ function SuggestMigrateTo2022Card({
onMigrateRequested: () => void;
}) {
return (
<Card className={"shrink-0 p-2 flex flex-row items-center compact:p-1"}>
<Card className={"shrink-0 p-2 flex flex-row items-center"}>
<p className="cursor-pointer py-1.5 font-bold grow-0 shrink overflow-hidden whitespace-normal text-sm pl-2">
{tc("projects:manage:suggest unity migration")}
</p>
@ -451,7 +459,7 @@ function Suggest2022PatchMigrationCard({
onMigrateRequested: () => void;
}) {
return (
<Card className={"shrink-0 p-2 flex flex-row items-center compact:p-1"}>
<Card className={"shrink-0 p-2 flex flex-row items-center"}>
<p className="cursor-pointer py-1.5 font-bold grow-0 shrink overflow-hidden whitespace-normal text-sm pl-2">
{tc("projects:manage:suggest unity patch migration")}
</p>
@ -475,7 +483,7 @@ function SuggestChinaToInternationalMigrationCard({
onMigrateRequested: () => void;
}) {
return (
<Card className={"shrink-0 p-2 flex flex-row items-center compact:p-1"}>
<Card className={"shrink-0 p-2 flex flex-row items-center"}>
<p className="cursor-pointer py-1.5 font-bold grow-0 shrink overflow-hidden whitespace-normal text-sm pl-2">
{tc("projects:manage:suggest unity china to international migration")}
</p>
@ -512,13 +520,13 @@ function ProjectViewHeader({
return (
<HNavBar
className={className}
className={`${className}`}
commonClassName={"min-h-12"}
leading={
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
className={"compact:h-10"}
variant={"ghost"}
size={"sm"}
onClick={() => history.back()}
@ -531,7 +539,7 @@ function ProjectViewHeader({
</TooltipContent>
</Tooltip>
<div className={"pl-2 space-y-0 shrink min-w-0 compact:pl-0"}>
<div className={"pl-2 space-y-0 my-1 shrink min-w-0"}>
<p className="cursor-pointer font-bold grow-0 whitespace-pre mb-0 leading-tight">
{projectName}
</p>
@ -583,6 +591,13 @@ function ProjectViewHeader({
);
}
function projectGetCustomUnityArgs(projectPath: string) {
return queryOptions({
queryKey: ["projectGetCustomUnityArgs", projectPath],
queryFn: async () => await commands.projectGetCustomUnityArgs(projectPath),
});
}
function LaunchSettings({
defaultUnityArgs,
initialValue,
@ -602,12 +617,12 @@ function LaunchSettings({
<>
<DialogTitle>{tc("projects:dialog:launch options")}</DialogTitle>
{/* TODO: use ScrollArea (I failed to use it inside dialog) */}
<div className={"max-h-[50dvh] overflow-y-auto"}>
<DialogDescription className={"max-h-[50dvh] overflow-y-auto"}>
<h3 className={"text-lg"}>
{tc("projects:dialog:command-line arguments")}
</h3>
<UnityArgumentsSettings context={context} />
</div>
</DialogDescription>
<DialogFooter>
<Button onClick={() => dialog.close(false)} variant={"destructive"}>
{tc("general:button:cancel")}
@ -735,37 +750,36 @@ function ProjectButton({
};
return (
<DropdownMenu>
<div className={"flex divide-x"}>
<OpenUnityButton
projectPath={projectPath}
unityVersion={unityVersion}
unityRevision={unityRevision}
className={"rounded-r-none pl-4 pr-3 compact:h-10"}
/>
<DropdownMenuTrigger
asChild
className={"rounded-l-none pl-2 pr-2 compact:h-10"}
>
<Button>
<ChevronDown className={"w-4 h-4"} />
</Button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent>
<DropdownMenuContentBody
projectPath={projectPath}
removeProject={() => {
void openSingleDialog(RemoveProjectDialog, {
project: {
path: projectPath,
is_exists: true,
},
});
}}
onChangeLaunchOptions={onChangeLaunchOptions}
/>
</DropdownMenuContent>
</DropdownMenu>
<>
<DropdownMenu>
<div className={"flex divide-x"}>
<OpenUnityButton
projectPath={projectPath}
unityVersion={unityVersion}
unityRevision={unityRevision}
className={"rounded-r-none pl-4 pr-3"}
/>
<DropdownMenuTrigger asChild className={"rounded-l-none pl-2 pr-2"}>
<Button>
<ChevronDown className={"w-4 h-4"} />
</Button>
</DropdownMenuTrigger>
</div>
<DropdownMenuContent>
<DropdownMenuContentBody
projectPath={projectPath}
removeProject={() => {
void openSingleDialog(RemoveProjectDialog, {
project: {
path: projectPath,
is_exists: true,
},
});
}}
onChangeLaunchOptions={onChangeLaunchOptions}
/>
</DropdownMenuContent>
</DropdownMenu>
</>
);
}

View file

@ -1,12 +1,12 @@
"use client";
import { createFileRoute, Outlet, useLocation } from "@tanstack/react-router";
import { useEffect, useState } from "react";
import { SideBar } from "@/components/SideBar";
import { commands } from "@/lib/bindings";
import { useDocumentEvent } from "@/lib/events";
import { updateCurrentPath, usePrevPathName } from "@/lib/prev-page";
import { useEffectEvent } from "@/lib/use-effect-event";
import { Outlet, createFileRoute, useLocation } from "@tanstack/react-router";
import { useEffect, useState } from "react";
export const Route = createFileRoute("/_main")({
component: MainLayout,
@ -57,23 +57,14 @@ function MainLayout() {
}, [pathName]);
useEffect(() => {
(async () => {
if (await commands.environmentGuiCompact()) {
document.documentElement.setAttribute("compact", "");
} else {
document.documentElement.removeAttribute("compact");
}
setIsVisible(true);
})();
setIsVisible(true);
}, []);
return (
<>
<SideBar
className={`grow-0 ${isVisible ? "slide-right" : "invisible"}`}
/>
<SideBar className={`grow-0 ${isVisible ? "slide-right" : ""}`} />
<div
className={`h-screen grow overflow-hidden flex p-4 compact:p-2 ${animationState}`}
className={`h-screen grow overflow-hidden flex p-4 ${animationState}`}
onAnimationEnd={() => setAnimationState("")}
>
<Outlet />

View file

@ -1,33 +1,26 @@
"use client";
import {
queryOptions,
useMutation,
useQueryClient,
useSuspenseQuery,
} from "@tanstack/react-query";
import { createFileRoute, Link } from "@tanstack/react-router";
import { RefreshCw } from "lucide-react";
import type React from "react";
import { Suspense, useEffect, useTransition } from "react";
import Loading from "@/app/-loading";
import { CheckForUpdateMessage } from "@/components/CheckForUpdateMessage";
import { ScrollPageContainer } from "@/components/ScrollPageContainer";
import { ScrollableCardTable } from "@/components/ScrollableCardTable";
import {
BackupFormatSelect,
BackupPathWarnings,
FilePathRow,
GuiAnimationSwitch,
GuiCompactSwitch,
LanguageSelector,
ProjectPathWarnings,
ThemeSelector,
} from "@/components/common-setting-parts";
import { HNavBar, HNavBarText, VStack } from "@/components/layout";
import { ScrollableCardTable } from "@/components/ScrollableCardTable";
import { ScrollPageContainer } from "@/components/ScrollPageContainer";
import { HNavBar, VStack } from "@/components/layout";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { DialogFooter, DialogTitle } from "@/components/ui/dialog";
import {
DialogDescription,
DialogFooter,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Tooltip,
@ -51,7 +44,17 @@ import {
toastThrownError,
} from "@/lib/toast";
import { useEffectEvent } from "@/lib/use-effect-event";
import { cn } from "@/lib/utils";
import {
queryOptions,
useMutation,
useQueryClient,
useSuspenseQuery,
} from "@tanstack/react-query";
import { Link, createFileRoute } from "@tanstack/react-router";
import { RefreshCw } from "lucide-react";
import { Suspense } from "react";
import { useTransition } from "react";
import { useEffect } from "react";
export const Route = createFileRoute("/_main/settings/")({
component: Page,
@ -66,8 +69,12 @@ function Page() {
return (
<VStack>
<HNavBar
className="shrink-0"
leading={<HNavBarText>{tc("settings")}</HNavBarText>}
className={"shrink-0"}
leading={
<p className="cursor-pointer py-1.5 font-bold grow-0">
{tc("settings")}
</p>
}
/>
<Suspense
fallback={
@ -124,18 +131,6 @@ function Settings() {
);
}
function SettingsCard({
className,
children,
...props
}: React.ComponentProps<typeof Card>) {
return (
<Card className={cn("shrink-0 p-4 compact:p-3", className)} {...props}>
{children}
</Card>
);
}
function UnityHubPathCard({
updateUnityPaths,
}: {
@ -180,7 +175,7 @@ function UnityHubPathCard({
});
return (
<SettingsCard>
<Card className={"shrink-0 p-4"}>
<h2 className={"pb-2"}>{tc("settings:unity hub path")}</h2>
<FilePathRow
path={unityHub}
@ -188,7 +183,7 @@ function UnityHubPathCard({
notFoundMessage={"Unity Hub Not Found"}
withOpen={false}
/>
</SettingsCard>
</Card>
);
}
@ -269,7 +264,7 @@ function UnityInstallationsCard({
];
return (
<SettingsCard className={"flex flex-col gap-2"}>
<Card className={"shrink-0 p-4 flex flex-col gap-2"}>
<div className={"flex align-middle"}>
<div className={"grow flex items-center"}>
<h2>{tc("settings:unity installations")}</h2>
@ -356,7 +351,7 @@ function UnityInstallationsCard({
{tc("settings:use legacy unity hub loading description")}
</p>
</div>
</SettingsCard>
</Card>
);
}
@ -370,7 +365,7 @@ function UnityLaunchArgumentsCard() {
const realUnityArgs = unityArgs ?? defaultUnityArgs;
return (
<SettingsCard>
<Card className={"shrink-0 p-4"}>
<div className={"mb-2 flex align-middle"}>
<div className={"grow flex items-center"}>
<h2>{tc("settings:default unity arguments")}</h2>
@ -397,11 +392,10 @@ function UnityLaunchArgumentsCard() {
</p>
<ol className={"flex flex-col"}>
{realUnityArgs.map((v, i) => (
// biome-ignore lint/suspicious/noArrayIndexKey: unity args are ordered list
<Input disabled key={i + v} value={v} className={"w-full"} />
))}
</ol>
</SettingsCard>
</Card>
);
}
@ -452,9 +446,9 @@ function LaunchArgumentsEditDialogBody({
{tc("settings:dialog:default launch arguments")}
</DialogTitle>
{/* TODO: use ScrollArea (I failed to use it inside dialog) */}
<div className={"max-h-[50dvh] overflow-y-auto"}>
<DialogDescription className={"max-h-[50dvh] overflow-y-auto"}>
<UnityArgumentsSettings context={context} />
</div>
</DialogDescription>
<DialogFooter>
<Button onClick={() => dialog.close(false)} variant={"destructive"}>
{tc("general:button:cancel")}
@ -511,7 +505,7 @@ function DefaultProjectPathCard() {
});
return (
<SettingsCard>
<Card className={"shrink-0 p-4"}>
<h2 className={"mb-2"}>{tc("settings:default project path")}</h2>
<p className={"whitespace-normal"}>
{tc("settings:default project path description")}
@ -521,7 +515,7 @@ function DefaultProjectPathCard() {
pick={pickProjectDefaultPath.mutate}
/>
<ProjectPathWarnings projectPath={defaultProjectPath} />
</SettingsCard>
</Card>
);
}
@ -584,7 +578,7 @@ function BackupCard() {
});
return (
<SettingsCard>
<Card className={"shrink-0 p-4"}>
<h2>{tc("projects:backup")}</h2>
<div className="mt-2">
<h3>{tc("settings:backup:path")}</h3>
@ -620,7 +614,7 @@ function BackupCard() {
{tc("settings:backup:exclude vpm packages from backup description")}
</p>
</div>
</SettingsCard>
</Card>
);
}
@ -677,7 +671,7 @@ function PackagesCard() {
});
return (
<SettingsCard className={"flex flex-col gap-4"}>
<Card className={"shrink-0 p-4 flex flex-col gap-4"}>
<h2>{tc("settings:packages")}</h2>
<div className={"flex flex-row flex-wrap gap-2"}>
<Button onClick={() => clearPackageCache.mutate()}>
@ -696,19 +690,18 @@ function PackagesCard() {
{tc("settings:show prerelease description")}
</p>
</div>
</SettingsCard>
</Card>
);
}
function AppearanceCard() {
return (
<SettingsCard className={"flex flex-col gap-2"}>
<Card className={"shrink-0 p-4"}>
<h2>{tc("settings:appearance")}</h2>
<LanguageSelector />
<ThemeSelector />
<GuiAnimationSwitch />
<GuiCompactSwitch />
</SettingsCard>
</Card>
);
}
@ -731,7 +724,7 @@ function FilesAndFoldersCard() {
};
return (
<SettingsCard>
<Card className={"shrink-0 p-4"}>
<h2>{tc("settings:files and directories")}</h2>
<p className={"mt-2"}>
{tc("settings:files and directories:description")}
@ -758,7 +751,7 @@ function FilesAndFoldersCard() {
{tc("settings:button:open vcc templates")}
</Button>
</div>
</SettingsCard>
</Card>
);
}
@ -870,7 +863,7 @@ function AlcomCard() {
};
return (
<SettingsCard className={"flex flex-col gap-4"}>
<Card className={"shrink-0 p-4 flex flex-col gap-4"}>
<h2>ALCOM</h2>
<div className={"flex flex-row flex-wrap gap-2"}>
{globalInfo.checkForUpdates && (
@ -935,7 +928,7 @@ function AlcomCard() {
},
)}
</p>
</SettingsCard>
</Card>
);
}
@ -943,7 +936,7 @@ function SystemInformationCard() {
const info = useGlobalInfo();
return (
<SettingsCard className={"flex flex-col gap-4"}>
<Card className={"shrink-0 p-4 flex flex-col gap-4"}>
<h2>{tc("settings:system information")}</h2>
<dl>
<dt>{tc("settings:os")}</dt>
@ -957,6 +950,6 @@ function SystemInformationCard() {
<dt>{tc("settings:alcom commit hash")}</dt>
<dd className={"opacity-50 mb-2"}>{info.commitHash}</dd>
</dl>
</SettingsCard>
</Card>
);
}

View file

@ -1,9 +1,9 @@
"use client";
import licenses from "build:licenses.json";
import { VStack } from "@/components/layout";
import { ScrollableCard } from "@/components/ScrollableCard";
import { ScrollPageContainer } from "@/components/ScrollPageContainer";
import { ScrollableCard } from "@/components/ScrollableCard";
import { VStack } from "@/components/layout";
import { Card } from "@/components/ui/card";
import { commands } from "@/lib/bindings";
@ -20,7 +20,7 @@ export default function RenderPage() {
<ul />
</Card>
{licenses.map((license) => (
{licenses.map((license, idx) => (
<Card className={"p-4"} key={license.text}>
<h3>{license.name}</h3>
<h4>Used by:</h4>

View file

@ -1,10 +1,10 @@
import licenses from "build:licenses.json";
import { createFileRoute } from "@tanstack/react-router";
import { VStack } from "@/components/layout";
import { ScrollableCard } from "@/components/ScrollableCard";
import { ScrollPageContainer } from "@/components/ScrollPageContainer";
import { ScrollableCard } from "@/components/ScrollableCard";
import { VStack } from "@/components/layout";
import { Card } from "@/components/ui/card";
import { commands } from "@/lib/bindings";
import { createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/_main/settings/licenses/")({
component: Page,
@ -23,7 +23,7 @@ function Page() {
<ul />
</Card>
{licenses.map((license) => (
{licenses.map((license, idx) => (
<Card className={"p-4"} key={license.text}>
<h3>{license.name}</h3>
<h4>Used by:</h4>

View file

@ -1,8 +1,8 @@
"use client";
import {
createFileRoute,
Outlet,
createFileRoute,
useNavigate,
useRouter,
} from "@tanstack/react-router";

View file

@ -1,13 +1,13 @@
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { Circle, CircleCheck, CircleChevronRight } from "lucide-react";
import type React from "react";
import { Button } from "@/components/ui/button";
import { Card, CardFooter, CardHeader } from "@/components/ui/card";
import type { SetupPages, TauriEnvironmentSettings } from "@/lib/bindings";
import { commands } from "@/lib/bindings";
import { useGlobalInfo } from "@/lib/global-info";
import { tc } from "@/lib/i18n";
import { useQuery } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { Circle, CircleCheck, CircleChevronRight } from "lucide-react";
import type React from "react";
export type BodyProps = Readonly<{
environment: TauriEnvironmentSettings;
@ -52,9 +52,9 @@ export function SetupPageBase({
<div className={"flex gap-4"}>
{!withoutSteps && <StepCard current={pageId} />}
<Card
className={`${withoutSteps ? "w-[30rem]" : "w-96"} min-w-[50vw] min-h-[max(50dvh,20rem)] p-4 flex gap-3 compact:min-h-[max(40dvh,20rem)]`}
className={`${withoutSteps ? "w-[30rem]" : "w-96"} min-w-[50vw] min-h-[max(50dvh,20rem)] p-4 flex gap-3`}
>
<div className={"flex flex-col grow gap-3 compact:gap-2"}>
<div className={"flex flex-col grow"}>
<CardHeader>
<h1 className={"text-center"}>{heading}</h1>
</CardHeader>
@ -65,7 +65,7 @@ export function SetupPageBase({
<Body environment={result.data} />
)}
<div className={"grow"} />
<CardFooter className="p-0 pt-3 items-end flex-row gap-2 justify-end compact:-m-2">
<CardFooter className="p-0 pt-3 items-end flex-row gap-2 justify-end">
{prevPage && (
<Button onClick={() => navigate({ to: prevPage })}>
{backContent}
@ -80,7 +80,11 @@ export function SetupPageBase({
);
}
function StepCard({ current }: { current: SetupPages | null }) {
function StepCard({
current,
}: {
current: SetupPages | null;
}) {
// TODO: get progress from backend
const finisheds = useQuery({
queryKey: ["environmentGetFinishedSetupPages"],

View file

@ -1,14 +1,13 @@
"use client";
import { createFileRoute } from "@tanstack/react-router";
import {
GuiAnimationSwitch,
GuiCompactSwitch,
LanguageSelector,
ThemeSelector,
} from "@/components/common-setting-parts";
import { CardDescription } from "@/components/ui/card";
import { tc } from "@/lib/i18n";
import { createFileRoute } from "@tanstack/react-router";
import { SetupPageBase } from "../-setup-page-base";
export const Route = createFileRoute("/_setup/setup/appearance/")({
@ -41,7 +40,6 @@ function Body() {
<LanguageSelector />
<ThemeSelector />
<GuiAnimationSwitch />
<GuiCompactSwitch />
</>
);
}

View file

@ -1,7 +1,5 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import {
BackupFormatSelect,
BackupPathWarnings,
@ -13,6 +11,8 @@ import { commands } from "@/lib/bindings";
import { useGlobalInfo } from "@/lib/global-info";
import { tc } from "@/lib/i18n";
import { toastError, toastSuccess, toastThrownError } from "@/lib/toast";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { type BodyProps, SetupPageBase } from "../-setup-page-base";
export const Route = createFileRoute("/_setup/setup/backups/")({

View file

@ -1,9 +1,9 @@
"use client";
import { createFileRoute } from "@tanstack/react-router";
import { CardDescription } from "@/components/ui/card";
import { useGlobalInfo } from "@/lib/global-info";
import { tc } from "@/lib/i18n";
import { createFileRoute } from "@tanstack/react-router";
import { SetupPageBase } from "../-setup-page-base";
export const Route = createFileRoute("/_setup/setup/finish/")({

View file

@ -1,7 +1,5 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import {
FilePathRow,
ProjectPathWarnings,
@ -11,6 +9,8 @@ import { assertNever } from "@/lib/assert-never";
import { commands } from "@/lib/bindings";
import { tc } from "@/lib/i18n";
import { toastError, toastSuccess, toastThrownError } from "@/lib/toast";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { type BodyProps, SetupPageBase } from "../-setup-page-base";
export const Route = createFileRoute("/_setup/setup/project-path/")({

View file

@ -1,5 +1,10 @@
"use client";
import { Checkbox } from "@/components/ui/checkbox";
import { commands } from "@/lib/bindings";
import { useGlobalInfo } from "@/lib/global-info";
import { tc } from "@/lib/i18n";
import { toastThrownError } from "@/lib/toast";
import {
queryOptions,
useMutation,
@ -7,11 +12,6 @@ import {
useQueryClient,
} from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { Checkbox } from "@/components/ui/checkbox";
import { commands } from "@/lib/bindings";
import { useGlobalInfo } from "@/lib/global-info";
import { tc } from "@/lib/i18n";
import { toastThrownError } from "@/lib/toast";
import { type BodyProps, SetupPageBase } from "../-setup-page-base";
export const Route = createFileRoute("/_setup/setup/system-setting/")({

View file

@ -1,7 +1,5 @@
"use client";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { FilePathRow } from "@/components/common-setting-parts";
import {
Accordion,
@ -15,6 +13,8 @@ import { assertNever } from "@/lib/assert-never";
import { commands } from "@/lib/bindings";
import { tc, tt } from "@/lib/i18n";
import { toastError, toastSuccess, toastThrownError } from "@/lib/toast";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { type BodyProps, SetupPageBase } from "../-setup-page-base";
export const Route = createFileRoute("/_setup/setup/unity-hub/")({

View file

@ -2,7 +2,6 @@
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@custom-variant compact (&:is([compact] *));
@theme inline {
--background-image-gradient-radial: radial-gradient(var(--tw-gradient-stops));
@ -99,7 +98,7 @@
--secondary: hsl(240 4.8% 95.9%);
--secondary-foreground: hsl(240 5.9% 10%);
--muted: hsl(240 4.8% 95.9%);
--muted-foreground: oklch(0.556 0 0);
--muted-foreground: hsl(240 3.8% 46.1%);
--accent: hsl(240 4.8% 95.9%);
--accent-foreground: hsl(240 5.9% 30%);
--info: hsl(207 90% 54%);
@ -135,7 +134,7 @@
--secondary: var(--secondary-bg);
--secondary-foreground: var(--fg-color);
--muted: var(--secondary-bg);
--muted-foreground: oklch(0.708 0 0);
--muted-foreground: 240 5% 74%;
--accent: var(--secondary-bg);
--accent-foreground: var(--fg-color);
--info: hsl(207 90% 54%);
@ -171,7 +170,7 @@
--secondary: var(--secondary-bg);
--secondary-foreground: var(--fg-color);
--muted: var(--secondary-bg);
--muted-foreground: oklch(0.708 0 0);
--muted-foreground: 240 5% 74%;
--accent: var(--secondary-bg);
--accent-foreground: var(--fg-color);
--info: hsl(207 90% 54%);
@ -188,7 +187,7 @@
}
}
body {
:root {
--toastify-font-family: var(--font-sans);
--toastify-color-light: var(--background);
/*--toastify-color-info: #3498db;*/
@ -287,7 +286,6 @@ html {
/* Radix ui sets display:block for each scroll viewport element but it seem it make worse */
[data-radix-scroll-area-viewport] > div {
/* biome-ignore lint/complexity/noImportantStyles: necessary to override element */
display: block !important;
}
@ -295,25 +293,13 @@ html {
* Add padding end for horizontal scroll bar of scrollable card if vertical scroll bar is invisible
* This prevents the horizontal scroll bar hide corner of the card
*/
.vrc-get-scrollable-card:not(
:has(> .vrc-get-scrollable-card-vertical-bar)
) > div[data-radix-scroll-area-viewport]
.vrc-get-scrollable-card:not(:has(> .vrc-get-scrollable-card-vertical-bar))
> div[data-radix-scroll-area-viewport]
> div
> div.vrc-get-scrollable-card-horizontal-bar {
@apply pe-2.5;
}
/*
* Add padding end for the content area of scrollable card if vertical scroll bar is visible
* This prevents the table / items from being hidden behind the vertical scroll bar
*/
.vrc-get-scrollable-card:has(
> .vrc-get-scrollable-card-vertical-bar
) > div[data-radix-scroll-area-viewport]
> div {
@apply pe-2.5;
}
.vrc-get-sidebar-hostname-warning-container {
contain-intrinsic-size: 0 7em;
contain: size;

View file

@ -1,5 +1,5 @@
import { createFileRoute, Outlet } from "@tanstack/react-router";
import ErrorPage from "@/app/-error";
import { Outlet, createFileRoute } from "@tanstack/react-router";
export const Route = createFileRoute("/")({
component: RouteComponent,
@ -7,5 +7,9 @@ export const Route = createFileRoute("/")({
});
function RouteComponent() {
return <Outlet />;
return (
<>
<Outlet />
</>
);
}

View file

@ -1,16 +1,15 @@
{
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"files": {
"includes": [
"**",
"!project-templates",
"!node_modules",
"!.next",
"!out",
"!gen",
"!lib/bindings.ts",
"!lib/routeTree.gen.ts",
"!build"
"ignore": [
"project-templates",
"node_modules",
".next",
"out",
"gen",
"lib/bindings.ts",
"lib/routeTree.gen.ts",
"build"
]
},
"formatter": {
@ -25,20 +24,9 @@
// In my opinion, '!.' => '?.' is not reasonable for all cases, so I disabled automatic fix.
"fix": "none",
"level": "error"
},
"noRestrictedGlobals": {
"level": "error",
"options": {
"deniedGlobals": {
"close": "window.close is unlikely to be called"
}
}
}
},
"suspicious": {
// false positives with tailwind css
// see https://github.com/biomejs/biome/issues/7223
"noUnknownAtRules": "off",
"noAssignInExpressions": "off"
},
"correctness": {
@ -75,16 +63,7 @@
},
"enabled": true
},
"assist": {
"actions": {
"source": {
"organizeImports": "on"
}
}
},
"css": {
"parser": {
"tailwindDirectives": true
}
"organizeImports": {
"enabled": true
}
}

View file

@ -1,3 +1,3 @@
thumbnail.png
thumbnail.*.svg
booth.zip
app-icon.png

View file

@ -1,11 +1,9 @@
npm.install:
npm install
app-icon.png: ../app-icon.png
cp ../app-icon.png app-icon.png
thumbnail.embed.svg: thumbnail.svg alcom-dark.png alcom-light.png ../app-icon.png
node embeder.mjs thumbnail.embed.svg thumbnail.svg alcom-dark.png alcom-light.png ../app-icon.png
thumbnail.png: thumbnail.svg alcom-dark.png alcom-light.png app-icon.png
rsvg-convert thumbnail.svg -o thumbnail.png
thumbnail.png: thumbnail.embed.svg
npx sharp -i thumbnail.embed.svg -o thumbnail.png
booth.zip: website.url README.ja.txt README.en.txt LICENSE
zip -r $@ $^

View file

@ -0,0 +1,15 @@
import * as fs from "node:fs/promises";
const output = process.argv[2];
const input = process.argv[3];
let inputText = await fs.readFile(input, { encoding: "utf-8" });
for (let i = 4; i < process.argv.length; i++) {
const embedPath = process.argv[i];
const embedDataBase64 = await fs.readFile(embedPath, { encoding: "base64" });
const embedDataUrl = `data:image/png;base64,${embedDataBase64}`;
inputText = inputText.replace(`"${embedPath}"`, `"${embedDataUrl}"`);
}
await fs.writeFile(output, inputText);

View file

@ -2,9 +2,9 @@
xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve"
>
<rect x="0" y="0" width="4096" height="4096" style="fill:rgb(45,45,45);"/>
<image x="-95" y="1013" width="3149px" height="2021" xlink:href="./alcom-light.png"/>
<image x="980" y="1670" width="3216px" height="2064" xlink:href="./alcom-dark.png"/>
<image x="254" y="138" width="844px" height="844px" xlink:href="./app-icon.png"/>
<image x="-95" y="1013" width="3149px" height="2021" xlink:href="alcom-light.png"/>
<image x="980" y="1670" width="3216px" height="2064" xlink:href="alcom-dark.png"/>
<image x="254" y="138" width="844px" height="844px" xlink:href="../app-icon.png"/>
<text x="1286" y="679" class="NotoSansBold Green">ALCOM</text>
<text x="1286" y="918" class="NotoSansMediumItalic Green">Fast VCC alternative for any desktop</text>

Before

Width:  |  Height:  |  Size: 3 KiB

After

Width:  |  Height:  |  Size: 3 KiB

Before After
Before After

View file

@ -100,5 +100,5 @@ fn get_commit_hash() {
return;
};
println!("cargo:rustc-env=COMMIT_HASH={hash_value}");
println!("cargo:rustc-env=COMMIT_HASH={}", hash_value);
}

View file

@ -1,62 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLName</key>
<string>ALCOM VCC URL</string>
<key>CFBundleURLSchemes</key>
<array>
<string>vcc</string>
</array>
</dict>
</array>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>ALCOM Project Template</string>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>LSItemContentTypes</key>
<array>
<string>com.anatawa12.vrc-get.alcomtemplate</string>
</array>
</dict>
</array>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeIdentifier</key>
<string>com.anatawa12.vrc-get.alcomtemplate</string>
<key>UTTypeIcons</key>
<dict>
<key>UTTypeIconText</key>
<string>Template</string>
<key>UTTypeIconBackgroundName</key>
<string></string>
<key>UTTypeIconBadgeName</key>
<string>icon.icns</string>
</dict>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
<string>public.content</string>
</array>
<key>UTTypeDescription</key>
<string>ALCOM Project Template</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>alcomtemplate</string>
</array>
</dict>
</dict>
</array>
</dict>
</plist>

View file

@ -1,9 +0,0 @@
[Desktop Entry]
Categories=Development
Comment=ALCOM - Alternative Creator Companion
Exec={{exec}} %u
Icon=alcom
Name=ALCOM
Terminal=false
Type=Application
MimeType=x-scheme-handler/vcc

View file

@ -1,70 +0,0 @@
Name: alcom
Version: 1.1.6
Release: 1%{?dist}
Summary: A short description of my custom application
%global git_version %(echo "%{version}" | tr '~' '-')
License: MIT
URL: https://vrc-get.anatawa12.com/alcom/
Source0: https://github.com/vrc-get/vrc-get/archive/gui-v%{git_version}.tar.gz
BuildRequires: gcc
BuildRequires: nodejs
BuildRequires: npm
BuildRequires: pkgconfig(gtk+-3.0)
BuildRequires: pkgconfig(webkit2gtk-4.1)
BuildRequires: pkgconfig(openssl)
# we download rust toolchain manually when building inside mock container
%if ! 0%{?install_rust:1}
BuildRequires: cargo
%endif
# disable stripping symbols.
%global __os_install_post %{nil}
%global debug_package %{nil}
%description
ALCOM - Alternative Creator Companion
ALCOM is a fast and open-source alternative VCC (VRChat Creator Companion) written in rust and tauri.
%prep
%setup -q -n vrc-get-gui-v%{git_version}
%if 0%{?install_rust:1}
echo "=== Mock environment detected. Installing isolated Rust toolchain ==="
export RUSTUP_HOME="$(pwd)/.rustup"
export CARGO_HOME="$(pwd)/.cargo"
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path
cat << EOF > ./load_rust_env.sh
export RUSTUP_HOME="${RUSTUP_HOME}"
export CARGO_HOME="${CARGO_HOME}"
source "${CARGO_HOME}/env"
EOF
%endif
# marker: ci inserts version update here
%build
%{?install_rust: source ./load_rust_env.sh}
cargo xtask build-alcom --release
%install
%{?install_rust: source ./load_rust_env.sh}
rm -rf %{buildroot}
cargo xtask bundle-alcom --release --bundles buildroot --buildroot=%{buildroot}
%files
%license LICENSE
# %doc vrc-get-gui/README.md
#%doc vrc-get-gui/CHANGELOG.md
%{_bindir}/alcom
%{_datadir}/applications/alcom.desktop
%{_datadir}/icons/hicolor/*/apps/alcom.png
%changelog
* Migrated to native rpm build pipeline with spec file

View file

@ -1,7 +0,0 @@
/*-build-stamp
/*.substvars
/.debhelper
/files
/cargo_home
/npm_cache
alcom/

View file

@ -1,5 +0,0 @@
alcom for Debian
ALCOM is a fast and open-source alternative VCC (VRChat Creator Companion) written in rust and tauri.
-- anatawa12 <i@anatawa12.com> Thu, 11 Jun 2026 14:34:57 +0000

View file

@ -1,20 +0,0 @@
alcom for Debian
This is README for alcom Debian package.
This Debian package is made to provide one of official distribution of alcom, formaly known as vrc-get-gui.
Starting with 1.1.7 or later, alcom maintainer switched from custom .deb toolchain to official .deb toolchain.
This directory contains source code of debian package as non-native package.
This directory is placed at vrc-get-gui/bundles/debian on original source tree, but before building debian package,
you must copy vrc-get-gui/bundles/debian to debian.
Violating best practice of debian package, this package requires network access, to download cargo dependencies on build.
In addition, by declaring `INSTALL_RUST` environment variable to `1` with `--set-envvar=INSTALL_RUST=1`, you can let
build process to install newest available rust to build on older distribution does not provide new enough rust version.
You also install NODEJS on build by declaring `INSTALL_NODEJS=1`.
Those options are helpful when building inside sandbox like pbuilder.
This Debian package is based on package generated by debmake Version 4.5.1.
-- anatawa12 <i@anatawa12.com> Thu, 11 Jun 2026 14:34:57 +0000

View file

@ -1,5 +0,0 @@
alcom (1.1.6-1) UNRELEASED; urgency=low
* No changelog are provided for this package
-- root <i@anatawa12.com> Thu, 11 Jun 2026 14:34:57 +0000

View file

@ -1,8 +0,0 @@
target/
vrc-get-gui/node_modules/
vrc-get-gui/out/
vrc-get-gui/gen/
debian/cargo_home/
debian/npm_cache/
debian/rustup_home/
debian/nodejs_installed/

View file

@ -1,29 +0,0 @@
Source: alcom
Priority: optional
Maintainer: anatawa12 <i@anatawa12.com>
Build-Depends:
debhelper-compat (= 13),
libssl-dev,
pkg-config,
cargo,
nodejs,
npm,
# curl for rust-install build only
curl,
libgtk-3-dev,
libwebkit2gtk-4.1-dev (>= 2.41),
Standards-Version: 4.7.0
Homepage: https://vrc-get.anatawa12.com/alcom/
Rules-Requires-Root: no
Package: alcom
Architecture: any
Multi-Arch: foreign
Depends:
${misc:Depends},
${shlibs:Depends},
Description: ALCOM - Alternative Creator Companion
ALCOM is a fast and open-source alternative VCC (VRChat Creator Companion) written in rust and tauri.
.
This package is one of official distribution of ALCOM, released as a part of the updates from ALCOM.
No packaging only updates will be provided.

View file

@ -1,41 +0,0 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: alcom
Upstream-Contact: <preferred name and address to reach the upstream project>
Source: <url://example.com>
Files: *
Copyright: Copyright (c) 2023 anatawa12 and other contribcmeutors
License: MIT
MIT License
.
Copyright (c) 2023 anatawa12 and other contributors
.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
.
Files: vrc-get-gui/third-party/Anton-Regular.ttf
vrc-get-gui/third-party/NotoSans-Italic-VariableFont_wdth,wght.ttf
vrc-get-gui/third-party/NotoSans-VariableFont_wdth,wght.ttf
Copyright: 2020 The Anton Project Authors (https://github.com/googlefonts/AntonFont.git)
2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)
License: SIL Open Font License 1.1
Files: vrc-get-gui/icons/*
Copyright: 2024 lilxyzw, anatawa12 and other contributors
License: CC-BY-4.0

View file

@ -1,39 +0,0 @@
#!/usr/bin/make -f
export DEB_BUILD_OPTIONS += nostrip
export CARGO_HOME = $(CURDIR)/debian/cargo_home
export NPM_CONFIG_CACHE = $(CURDIR)/debian/npm_cache
%:
dh $@
# install rust in configure phase when requested
ifeq ($(INSTALL_RUST),1)
export RUSTUP_HOME = $(CURDIR)/debian/rustup_home
export PATH := $(CURDIR)/debian/cargo_home/bin:$(PATH)
override_dh_auto_configure::
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path
endif
ifeq ($(INSTALL_NODEJS),1)
export RUSTUP_HOME = $(CURDIR)/debian/rustup_home
export PATH := $(CURDIR)/debian/nodejs_installed/bin:$(PATH)
ifeq ($(DEB_HOST_ARCH),amd64)
NODE_ARCH := x64
else ifeq ($(DEB_HOST_ARCH),arm64)
NODE_ARCH := arm64
endif
override_dh_auto_configure::
mkdir -p $(CURDIR)/debian/nodejs_installed
curl --proto '=https' --tlsv1.2 -sSf https://nodejs.org/dist/v26.3.0/node-v26.3.0-linux-$(NODE_ARCH).tar.gz | gunzip | tar x --strip-components 1 -C $(CURDIR)/debian/nodejs_installed
endif
override_dh_auto_build:
cargo xtask build-alcom --release
override_dh_auto_install:
cargo xtask bundle-alcom --release --bundles buildroot --buildroot=$(CURDIR)/debian/alcom

View file

@ -1 +0,0 @@
3.0 (quilt)

View file

@ -1,24 +0,0 @@
# Metadata about the upstream project.
# See https://wiki.debian.org/UpstreamMetadata
Bug-Database: https://github.com/vrc-get/vrc-get/issues
Bug-Submit: https://github.com/vrc-get/vrc-get/issues/new
Changelog: https://github.com/vrc-get/vrc-get/blob/master/vrc-get-gui/CHANGELOG.md
Documentation: https://vrc-get.anatawa12.com/alcom/
Repository-Browse: https://github.com/vrc-get/vrc-get
Repository: https://github.com/vrc-get/vrc-get.git
#FAQ: https://github.com/<user>/<project>/blob/main/FAQ.md
Donation: https://github.com/sponsors/anatawa12
#Registration: https://github.com/signup
#Archive: PyPI # or CPAN, boost, etc.
# Uncomment these and fill them out to help users evaluate upstream:
#Gallery: https://github.com/<user>/<project>/wiki/pictures-made-with-this-program
#Screenshots: # pictures *of* the program, as opposed to pictures *made with* the program
# - https://github.com/<user>/<project>/wiki/login-screen.png
# - https://github.com/<user>/<project>/wiki/help-menu.png
# - ...
# Uncomment these and fill them out to help resolve security issues:
#Security-Contact: <how to send security-related messages>
#CPE: <space-separated Common Platform Enumerator values - see https://wiki.debian.org/CPEtagPackagesDep>

View file

@ -1,10 +0,0 @@
# You must remove unused comment lines for the released package.
# Compulsory line, this is a version 4 file
version=4
opts=\
filenamemangle=s%.*/@ANY_VERSION@%@PACKAGE@-$1.tar.gz%,\
downloadurlmangle=s%(api.github.com/repos/[^/]+/[^/]+)/git/refs/%$1/tarball/refs/%g,\
uversionmangle=s/(\d)[-]?((RC|rc|pre|dev|beta|alpha)\.\d*)$/$1~$2/,\
searchmode=plain \
https://api.github.com/repos/vrc-get/vrc-get/git/matching-refs/tags/gui- \
https://api.github.com/repos/[^/]+/[^/]+/git/refs/tags/gui-@ANY_VERSION@

View file

@ -1,211 +0,0 @@
; Script generated by the Inno Setup Script Wizard.
; SEE THE DOCUMENTATION FOR DETAILS ON CREATING INNO SETUP SCRIPT FILES!
; Non-commercial use only
#ifndef ApplicationVersion
#error ApplicationVersion is not defined. Define with -DApplicationVersion=
#endif
#ifndef WebView2SetupPath
#error WebView2SetupPath is not defined. Define with -DWebView2SetupPath=
#endif
#ifndef ApplicationPath
#error ApplicationPath is not defined. Define with -DApplicationPath=LicensePath
#endif
#ifndef LicensePath
#error LicensePath is not defined. Define with -DLicensePath=
#endif
#define MyAppName "ALCOM"
#define MyAppPublisher "anatawa12"
#define MyAppURL "https://vrc-get.anatawa12.com/alcom/"
#define MyAppExeName "ALCOM.exe"
#define MyAppAssocName "ALCOM Template"
#define MyAppAssocExt ".alcomtemplate"
#define MyAppAssocKey "ALCOM Project Template"
[Setup]
AppId={{4C3D0631-AE29-4D20-A231-678D9CF8D6DB}
AppName={#MyAppName}
AppVersion={#ApplicationVersion}
AppPublisher={#MyAppPublisher}
AppPublisherURL={#MyAppURL}
AppSupportURL={#MyAppURL}
AppUpdatesURL={#MyAppURL}
DefaultDirName={autopf}\{#MyAppName}
UninstallDisplayIcon={app}\{#MyAppExeName}
; "ArchitecturesAllowed=x64compatible" specifies that Setup cannot run
; on anything but x64 and Windows 11 on Arm.
ArchitecturesAllowed=x64compatible
; "ArchitecturesInstallIn64BitMode=x64compatible" requests that the
; install be done in "64-bit mode" on x64 or Windows 11 on Arm,
; meaning it should use the native 64-bit Program Files directory and
; the 64-bit view of the registry.
ArchitecturesInstallIn64BitMode=x64compatible
ChangesAssociations=yes
DisableProgramGroupPage=yes
LicenseFile={#LicensePath}
; Remove the following line to run in administrative install mode (install for all users).
PrivilegesRequired=lowest
PrivilegesRequiredOverridesAllowed=dialog
OutputBaseFilename=alcom
SolidCompression=yes
WizardStyle=modern dynamic
; allow users to install ALCOM to different location than before.
; this would cause ALCOM to be installed to multiple location, but user may move ALCOM
; without uninstalling ALCOM so this is 'safer' option than normal one.
DisableDirPage=no
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
Name: "japanese"; MessagesFile: "compiler:Languages\Japanese.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Files]
Source: "{#ApplicationPath}"; DestDir: "{app}"; DestName: "{#MyAppExeName}"; Flags: ignoreversion
Source: "{#WebView2SetupPath}"; DestName: "MicrosoftEdgeWebView2Setup.exe"; DestDir: "{tmp}"; Flags: deleteafterinstall
; NOTE: Don't use "Flags: ignoreversion" on any shared system files
[Registry]
Root: HKA; Subkey: "Software\Classes\{#MyAppAssocExt}\OpenWithProgids"; ValueType: string; ValueName: "{#MyAppAssocKey}"; ValueData: ""; Flags: uninsdeletevalue
Root: HKA; Subkey: "Software\Classes\{#MyAppAssocKey}"; ValueType: string; ValueName: ""; ValueData: "{#MyAppAssocName}"; Flags: uninsdeletekey
Root: HKA; Subkey: "Software\Classes\{#MyAppAssocKey}\DefaultIcon"; ValueType: string; ValueName: ""; ValueData: "{app}\{#MyAppExeName},0"
Root: HKA; Subkey: "Software\Classes\{#MyAppAssocKey}\shell\open\command"; ValueType: string; ValueName: ""; ValueData: """{app}\{#MyAppExeName}"" ""%1"""
[Icons]
Name: "{autoprograms}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
[Run]
; No skipifsilent to relaunch after application quit
Filename: "{app}\{#MyAppExeName}"; Description: "{cm:LaunchProgram,{#StringChange(MyAppName, '&', '&&')}}"; Flags: nowait postinstall runasoriginaluser
[Code]
// NSIS to inno setup migrations
// There are many differences between nsis and inno setup.
// (There are more changes than following in default settings but can be fixed by changing settings)
// We'll fix following changes in each ways.
// (Following uses bash-like fallback style: ${Variable:-fallbackValue} and HKLM/-style path for reference
// - default installation path changes
// NSIS: ${LOCALAPPDATA}\ALCOM
// Inno Setup: ${FOLDERID_UserProgramFiles:-${LOCALAPPDATA}/Programs}/${AppName}
// No relocation will be done for installation location since updating will break existing shortcuts.
// We'll let inno setup to use old location when no inno setup detected
// - Registry key for remove entry
// NSIS: HKA\Software\Microsoft\Windows\CurrentVersion\Uninstall\ALCOM
// Inno Setup: HKA\Software\Microsoft\Windows\CurrentVersion\Uninstall\${AppId}_is1
// (source: https://github.com/jrsoftware/issrc/blob/7d001a7eaa056a4b43bc89f6ff09c4edd213585d/Projects/Src/Setup.MainFunc.pas#L3123)
// We will remove the old key
// - Previous installation registry entry
// NSIS: HKA\Software\anatawa12\vrc-get-gui ""
// Inno Setup: HKA\Software\Microsoft\Windows\CurrentVersion\Uninstall\${AppId}_is1 "Inno Setup: App Path"
// We will remove the old key and copy previous install to new one
// - Previous installer language
// NSIS: HKA\Software\anatawa12\vrc-get-gui "Installer Language"
// Inno Setup: HKA\Software\Microsoft\Windows\CurrentVersion\Uninstall\${AppId}_is1 "Inno Setup: Language"
// We will remove the old key, but no migration will be done
var NsisMigration: Boolean;
procedure InitializeWizard();
var
NsisInstallPath: string;
begin
if (WizardForm.PrevAppDir = '') and not IsAdminInstallMode then
begin
// Inno setup previous installation not found.
// We'll find NSIS install information
Log('No Inno Setup installation found while single user installation is enabled, trying migration from NSIS');
if RegQueryStringValue(HKEY_CURRENT_USER, 'Software\anatawa12\vrc-get-gui', '', NsisInstallPath) then
begin
Log('Previous NSIS installation path found: ' + NsisInstallPath);
NsisMigration := True;
WizardForm.DirEdit.Text := NsisInstallPath;
// We want to set PrevAppDir to new value to prevent 'directly alredy exists' warning,
// but pascal runtime in inno setup doesn't allow us to access private fields so we leave it.
end
end;
end;
procedure PostInstallNsisMigration();
begin
if NsisMigration then
begin
// remove old uninstall entry
RegDeleteKeyIncludingSubkeys(HKEY_CURRENT_USER, 'Software\anatawa12\vrc-get-gui');
RegDeleteKeyIfEmpty(HKEY_CURRENT_USER, 'Software\anatawa12');
RegDeleteKeyIncludingSubkeys(HKEY_CURRENT_USER, 'Software\Microsoft\Windows\CurrentVersion\Uninstall\ALCOM');
// remove uninstaller from NSIS
DeleteFile(ExpandConstant('{app}\uninstall.exe'))
end;
end;
// webview2 installation
const WebView2RegKey = 'SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}';
// Check both HKLM32 and HKCU for user install.
// Check HKLM32 twice for system install.
// We always check HKLM32 even our system use 64bit.
function IsWebView2Installed: Boolean;
begin
Result := RegKeyExists(HKLM32, WebView2RegKey) or RegKeyExists(HKA, WebView2RegKey);
end;
function InstallWebView2: Boolean;
var
InstallerPath: string;
ResultCode: Integer;
begin
Result := False;
InstallerPath :=
ExpandConstant('{tmp}\MicrosoftEdgeWebView2Setup.exe');
if not Exec(
InstallerPath,
'/silent /install',
'',
SW_HIDE,
ewWaitUntilTerminated,
ResultCode
) then
begin
MsgBox('Failed to launch WebView2 installer.', mbError, MB_OK);
exit;
end;
if ResultCode <> 0 then
begin
MsgBox(
'WebView2 installation failed. Exit code: ' + IntToStr(ResultCode),
mbError,
MB_OK
);
exit;
end;
Result := True;
end;
procedure CurStepChanged(CurStep: TSetupStep);
begin
if CurStep = ssInstall then
begin
if not IsWebView2Installed then
begin
if not InstallWebView2 then
begin
Abort;
end;
end;
end;
if CurStep = ssPostInstall then
begin
PostInstallNsisMigration;
end;
end;

View file

@ -1,7 +1,9 @@
import type React from "react";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { DialogFooter, DialogTitle } from "@/components/ui/dialog";
import {
DialogDescription,
DialogFooter,
DialogTitle,
} from "@/components/ui/dialog";
import { Progress } from "@/components/ui/progress";
import type { TauriCreateBackupProgress } from "@/lib/bindings";
import { commands } from "@/lib/bindings";
@ -10,6 +12,9 @@ import type { DialogContext } from "@/lib/dialog";
import { tc } from "@/lib/i18n";
import { toastNormal, toastSuccess } from "@/lib/toast";
import { useEffectEvent } from "@/lib/use-effect-event";
import type React from "react";
import { useRef } from "react";
import { useEffect, useState } from "react";
export function BackupProjectDialog({
projectPath,
@ -65,7 +70,7 @@ export function BackupProjectDialog({
return (
<div className={"contents whitespace-normal"}>
<DialogTitle>{header ?? tc("projects:dialog:backup header")}</DialogTitle>
<div>
<DialogDescription>
<p>{tc("projects:dialog:creating backup...")}</p>
<p>
{tc("projects:dialog:proceed k/n", {
@ -77,7 +82,7 @@ export function BackupProjectDialog({
{progress.last_proceed || "Collecting files..."}
</p>
<Progress value={progress.proceed} max={progress.total} />
</div>
</DialogDescription>
<DialogFooter>
<Button className="mr-1" onClick={() => cancelRef.current?.()}>
{tc("general:button:cancel")}

View file

@ -1,7 +1,10 @@
import React, { useState } from "react";
import { ExternalLink } from "@/components/ExternalLink";
import { Button } from "@/components/ui/button";
import { DialogFooter, DialogTitle } from "@/components/ui/dialog";
import {
DialogDescription,
DialogFooter,
DialogTitle,
} from "@/components/ui/dialog";
import { Progress } from "@/components/ui/progress";
import { assertNever } from "@/lib/assert-never";
import type { CheckForUpdateResponse } from "@/lib/bindings";
@ -9,7 +12,8 @@ import { commands } from "@/lib/bindings";
import { callAsyncCommand } from "@/lib/call-async-command";
import type { DialogContext } from "@/lib/dialog";
import globalInfo from "@/lib/global-info";
import { localizeExternalComponent, tc } from "@/lib/i18n";
import { tc } from "@/lib/i18n";
import React, { useState } from "react";
type ConfirmStatus =
| {
@ -95,59 +99,13 @@ export function CheckForUpdateMessage({
}
};
const openAlcomWebsite = async () => {
await commands.utilOpenUrl("https://an12.net/alcom/");
};
switch (confirmStatus.state) {
case "confirming": {
let message: React.ReactNode;
switch (response.updater_status) {
case "Updatable":
message = <p>{tc("check update:dialog:new version description")}</p>;
break;
case "NoPlatform":
message = (
<p>
{tc("check update:dialog:new version no platform description")}
</p>
);
break;
case "NotUpdatable":
message = (
<p>
{tc("check update:dialog:new version not updatable description")}
</p>
);
break;
case "UpdaterDisabled":
message = (
<p>
{tc(
"check update:dialog:new version updater disabled base description",
)}
<br />
{localizeExternalComponent(response.updater_disabled_messages, {
localized:
"check update:dialog:new version updater how to upgrade fallback",
})}
</p>
);
break;
default:
assertNever(response.updater_status);
}
const withDownloadButton = response.updater_status === "Updatable";
const withDownloadLink =
!withDownloadButton && response.updater_status !== "UpdaterDisabled";
case "confirming":
return (
<>
<DialogTitle>{tc("check update:dialog:title")}</DialogTitle>
<div>
{message}
<DialogDescription>
<p>{tc("check update:dialog:new version description")}</p>
<p>
{tc("check update:dialog:current version")}{" "}
{response.current_version}
@ -164,45 +122,37 @@ export function CheckForUpdateMessage({
}
/>
</p>
</div>
</DialogDescription>
<DialogFooter className={"gap-2"}>
<Button onClick={() => dialog.close(false)}>
{tc("check update:dialog:dismiss")}
</Button>
{withDownloadButton && (
<Button onClick={startDownload}>
{tc("check update:dialog:update")}
</Button>
)}
{withDownloadLink && (
<Button onClick={openAlcomWebsite}>
{tc("check update:dialog:open download page")}
</Button>
)}
<Button onClick={startDownload}>
{tc("check update:dialog:update")}
</Button>
</DialogFooter>
</>
);
}
case "downloading":
return (
<>
<DialogTitle>{tc("check update:dialog:title")}</DialogTitle>
<div>
<DialogDescription>
<p>{tc("check update:dialog:downloading...")}</p>
<Progress
value={confirmStatus.downloaded}
max={confirmStatus.total}
/>
</div>
</DialogDescription>
</>
);
case "waitingForRelaunch":
return (
<>
<DialogTitle>{tc("check update:dialog:title")}</DialogTitle>
<div>
<DialogDescription>
<p>{tc("check update:dialog:relaunching...")}</p>
</div>
</DialogDescription>
</>
);
}
@ -210,7 +160,7 @@ export function CheckForUpdateMessage({
const LinkedText = React.memo(({ text }: { text: string }) => {
const urlRegex =
/https:\/\/[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)+\/[a-zA-Z0-9$\-_.+!*'()%/?#]*/g;
/https:\/\/[a-zA-Z0-9]+(?:\.[a-zA-Z0-9]+)+\/[a-zA-Z0-9$\-_.+!*'()%\/?#]*/g;
const components: React.ReactNode[] = [];
let lastMatchEnd = 0;
for (const match of text.matchAll(urlRegex)) {

View file

@ -1,6 +1,6 @@
import { Button } from "@/components/ui/button";
import type React from "react";
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
export function DelayedButton({
disabled,
delay,

View file

@ -1,7 +1,7 @@
import { ExternalLink as LucideExternalLink } from "lucide-react";
import type React from "react";
import { commands } from "@/lib/bindings";
import { cn } from "@/lib/utils";
import { ExternalLink as LucideExternalLink } from "lucide-react";
import type React from "react";
export function ExternalLink({
children,
@ -25,7 +25,7 @@ export function ExternalLink({
<a
className={cn(className, "underline inline")}
type={"button"}
href={href}
// biome-ignore lint/a11y/useValidAnchor: This is navigation with external browser, not a action
onClick={() => commands.utilOpenUrl(href)}
>
{body}

View file

@ -1,54 +0,0 @@
import { Star, StarOff } from "lucide-react";
import { cn } from "@/lib/utils";
export function FavoriteStarToggleButton({
favorite,
disabled,
onToggle,
className,
}: {
favorite: boolean;
disabled?: boolean;
onToggle?: () => void;
className?: string;
}) {
if (disabled) {
return (
<StarOff
strokeWidth={favorite ? 1.5 : 3}
className={cn(
"size-4 transition-colors cursor-pointer",
"text-foreground/30",
"opacity-0 group-hover:opacity-100",
"hover:opacity-100",
className,
)}
fill={favorite ? "currentColor" : "none"}
onClick={() => {
if (!disabled) {
onToggle?.();
}
}}
/>
);
} else {
return (
<Star
strokeWidth={favorite ? 1.5 : 3}
className={cn(
"size-4 transition-colors cursor-pointer",
favorite ? "text-foreground" : "text-foreground/30",
!favorite && "opacity-0 group-hover:opacity-100",
"hover:text-foreground hover:opacity-100",
className,
)}
fill={favorite ? "currentColor" : "none"}
onClick={() => {
if (!disabled) {
onToggle?.();
}
}}
/>
);
}
}

View file

@ -1,10 +1,11 @@
import { queryOptions, useQueryClient } from "@tanstack/react-query";
import type React from "react";
import { useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { commands } from "@/lib/bindings";
import { tc } from "@/lib/i18n";
import { openUnity } from "@/lib/open-unity";
import { queryOptions, useQueryClient } from "@tanstack/react-query";
import type React from "react";
import { useState } from "react";
import { useRef } from "react";
function PreventDoubleClick({
delayMs,

View file

@ -1,5 +1,5 @@
import React from "react";
import { cn } from "@/lib/utils";
import React from "react";
/**
* Overlays multiple elements to one place with grid layout
@ -13,13 +13,10 @@ import { cn } from "@/lib/utils";
export function Overlay({
children,
className,
}: {
className?: string;
children?: React.ReactNode;
}) {
}: { className?: string; children?: React.ReactNode }) {
return (
<div className={cn("grid", className)}>
{React.Children.map(children, (child) => {
{React.Children.map(children, (child, i) => {
if (React.isValidElement(child)) {
const childElement = child as React.ReactHTMLElement<HTMLElement>;
return React.cloneElement(childElement, {

View file

@ -1,12 +1,16 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useLocation, useRouter } from "@tanstack/react-router";
import { Button } from "@/components/ui/button";
import { DialogFooter, DialogTitle } from "@/components/ui/dialog";
import {
DialogDescription,
DialogFooter,
DialogTitle,
} from "@/components/ui/dialog";
import { commands } from "@/lib/bindings";
import type { DialogContext } from "@/lib/dialog";
import { tc, tt } from "@/lib/i18n";
import { nameFromPath } from "@/lib/os";
import { toastSuccess, toastThrownError } from "@/lib/toast";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useLocation, useRouter } from "@tanstack/react-router";
type Project = {
path: string;
@ -28,10 +32,7 @@ export function RemoveProjectDialog({
mutationFn: async ({
project,
removeDir,
}: {
project: Project;
removeDir: boolean;
}) => {
}: { project: Project; removeDir: boolean }) => {
await commands.environmentRemoveProjectByPath(project.path, removeDir);
},
onSuccess: () => {
@ -59,7 +60,7 @@ export function RemoveProjectDialog({
return (
<div className={"contents whitespace-normal"}>
<DialogTitle>{tc("projects:remove project")}</DialogTitle>
<div>
<DialogDescription>
{removeProject.isPending ? (
<p className={"font-normal"}>{tc("projects:dialog:removing...")}</p>
) : (
@ -69,7 +70,7 @@ export function RemoveProjectDialog({
})}
</p>
)}
</div>
</DialogDescription>
<DialogFooter className={"flex gap-2"}>
<Button
onClick={() => dialog.close(false)}

View file

@ -1,3 +1,5 @@
import { Button } from "@/components/ui/button";
import { assertNever } from "@/lib/assert-never";
import { ArrowDown, ArrowUp, CircleMinus, CirclePlus } from "lucide-react";
import type React from "react";
import {
@ -7,13 +9,11 @@ import {
useMemo,
useState,
} from "react";
import { Button } from "@/components/ui/button";
import { assertNever } from "@/lib/assert-never";
const internalSymbol: unique symbol = Symbol("ReorderableListContextInternal");
const idSymbol: unique symbol = Symbol("IdSymbol");
export type ReorderableListId = { [idSymbol]: number; toString: () => string };
type Id = { [idSymbol]: number; toString: () => string };
type NonFunction =
| string
@ -25,9 +25,9 @@ type NonFunction =
| bigint
| object;
type AddOptions = { after: ReorderableListId } | { before: ReorderableListId };
type AddOptions = { after: Id } | { before: Id };
type ReordeableListValue<T> = { id: ReorderableListId; value: T };
type ReordeableListValue<T> = { id: Id; value: T };
type ReorderableListContextInternal<T> = {
backedList: ReordeableListValue<T>[];
@ -40,8 +40,8 @@ type ReorderableListContextInternal<T> = {
export type ReorderableListContext<T> = {
setList: Dispatch<SetStateAction<T[]>>;
add: (value: T, options?: AddOptions) => void;
remove: (id: ReorderableListId) => void;
update: (id: ReorderableListId, action: SetStateAction<T>) => void;
remove: (id: Id) => void;
update: (id: Id, action: SetStateAction<T>) => void;
get value(): T[];
[internalSymbol]: ReorderableListContextInternal<T>;
};
@ -127,7 +127,7 @@ export function useReorderableList<T extends NonFunction>({
}, []);
const remove = useCallback(
(id: ReorderableListId) => {
(id: Id) => {
setBackedList((old) => {
let list = old.filter(({ id: _id }) => _id !== id);
if (list.length === 0 && !allowEmpty) list = [makeValue(defaultValue)];
@ -137,29 +137,26 @@ export function useReorderableList<T extends NonFunction>({
[allowEmpty, defaultValue],
);
const update = useCallback(
(id: ReorderableListId, action: SetStateAction<T>) => {
if (typeof action === "function") {
setBackedList((old) => {
const idx = old.findIndex(({ id: _id }) => _id === id);
if (idx === -1) return old;
const newValue = action(old[idx].value);
const newArray = [...old];
newArray[idx] = { id, value: newValue };
return newArray;
});
} else {
setBackedList((old) => {
const idx = old.findIndex(({ id: _id }) => _id === id);
if (idx === -1) return old;
const newArray = [...old];
newArray[idx] = { id, value: action };
return newArray;
});
}
},
[],
);
const update = useCallback((id: Id, action: SetStateAction<T>) => {
if (typeof action === "function") {
setBackedList((old) => {
const idx = old.findIndex(({ id: _id }) => _id === id);
if (idx === -1) return old;
const newValue = action(old[idx].value);
const newArray = [...old];
newArray[idx] = { id, value: newValue };
return newArray;
});
} else {
setBackedList((old) => {
const idx = old.findIndex(({ id: _id }) => _id === id);
if (idx === -1) return old;
const newArray = [...old];
newArray[idx] = { id, value: action };
return newArray;
});
}
}, []);
const swap = useCallback((index1: number, index2: number) => {
setBackedList((old) => {
@ -171,15 +168,14 @@ export function useReorderableList<T extends NonFunction>({
});
}, []);
return useMemo(() => {
let valueCache: T[] | undefined;
return {
return useMemo(
() => ({
setList,
add,
update,
remove,
get value() {
return (valueCache ??= backedList.map(({ value }) => value));
return backedList.map(({ value }) => value);
},
[internalSymbol]: {
backedList,
@ -188,18 +184,19 @@ export function useReorderableList<T extends NonFunction>({
reorderable,
addable,
},
};
}, [
setList,
add,
update,
remove,
backedList,
defaultValue,
swap,
reorderable,
addable,
]);
}),
[
setList,
add,
update,
remove,
backedList,
defaultValue,
swap,
reorderable,
addable,
],
);
}
export function ReorderableList<T>({
@ -209,7 +206,7 @@ export function ReorderableList<T>({
disabled,
}: {
context: ReorderableListContext<T>;
renderItem: (value: T, id: ReorderableListId) => React.ReactNode;
renderItem: (value: T, id: Id) => React.ReactNode;
ifEmpty?: () => React.ReactNode;
disabled?: boolean;
}) {

View file

@ -11,7 +11,7 @@ export function ScrollPageContainer({
}) {
return (
<ScrollArea
className={`-mr-3 pr-3 compact:mr-0 ${className}`}
className={`-mr-3 pr-3 ${className}`}
scrollBarClassName={"bg-background rounded-full border-l-0 p-[1.5px]"}
viewportClassName={`${viewportClassName}`}
>

Some files were not shown because too many files have changed in this diff Show more