Skip to content

Commit e50d71b

Browse files
committed
feat: make DevStack node globally selectable
1 parent d016f95 commit e50d71b

9 files changed

Lines changed: 208 additions & 30 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "tauri-app",
33
"private": true,
4-
"version": "1.0.3",
4+
"version": "1.0.4",
55
"type": "module",
66
"scripts": {
77
"dev": "vite",

src-tauri/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "tauri-app"
3-
version = "1.0.3"
3+
version = "1.0.4"
44
description = "A Tauri App"
55
authors = ["you"]
66
edition = "2021"

src-tauri/src/lib.rs

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1313,6 +1313,129 @@ if (Test-Path -LiteralPath $current) {{
13131313
}
13141314
}
13151315

1316+
#[derive(Debug, Serialize)]
1317+
struct NodePathStatus {
1318+
current_node_path: Option<String>,
1319+
all_node_paths: Vec<String>,
1320+
devstack_first: bool,
1321+
user_path_contains_devstack: bool,
1322+
}
1323+
1324+
#[tauri::command]
1325+
fn get_node_path_status(base_dir: String) -> Result<NodePathStatus, String> {
1326+
#[cfg(target_os = "windows")]
1327+
{
1328+
let devstack_current = node_current_dir(&base_dir)
1329+
.to_string_lossy()
1330+
.replace("/", "\\")
1331+
.trim_end_matches('\\')
1332+
.to_string();
1333+
1334+
let output = run_hidden_powershell(
1335+
r#"
1336+
$cmd = Get-Command node -All -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source -Unique
1337+
if ($cmd) {
1338+
$cmd | ForEach-Object { Write-Output $_ }
1339+
}
1340+
"#,
1341+
)?;
1342+
1343+
if !output.status.success() {
1344+
return Err(String::from_utf8_lossy(&output.stderr).trim().to_string());
1345+
}
1346+
1347+
let paths = String::from_utf8_lossy(&output.stdout)
1348+
.lines()
1349+
.map(|line| line.trim().replace("/", "\\"))
1350+
.filter(|line| !line.is_empty())
1351+
.collect::<Vec<_>>();
1352+
1353+
let current_node_path = paths.first().cloned();
1354+
let devstack_prefix = format!("{}\\node.exe", devstack_current);
1355+
let devstack_first = current_node_path
1356+
.as_ref()
1357+
.map(|path| path.eq_ignore_ascii_case(&devstack_prefix))
1358+
.unwrap_or(false);
1359+
1360+
let user_path = std::env::var("PATH").unwrap_or_default();
1361+
let user_path_contains_devstack = user_path
1362+
.split(';')
1363+
.map(|part| part.trim().replace("/", "\\").trim_end_matches('\\').to_string())
1364+
.any(|part| part.eq_ignore_ascii_case(&devstack_current));
1365+
1366+
Ok(NodePathStatus {
1367+
current_node_path,
1368+
all_node_paths: paths,
1369+
devstack_first,
1370+
user_path_contains_devstack,
1371+
})
1372+
}
1373+
#[cfg(not(target_os = "windows"))]
1374+
{
1375+
let _ = base_dir;
1376+
Err("Global Node PATH management is currently only supported on Windows".into())
1377+
}
1378+
}
1379+
1380+
#[tauri::command]
1381+
fn set_node_global_path(base_dir: String) -> Result<(), String> {
1382+
#[cfg(target_os = "windows")]
1383+
{
1384+
let devstack_current = node_current_dir(&base_dir);
1385+
if !devstack_current.join("node.exe").exists() {
1386+
return Err("No active DevStack Node version found. Activate a Node version first.".into());
1387+
}
1388+
1389+
let target = devstack_current
1390+
.to_string_lossy()
1391+
.replace("/", "\\")
1392+
.trim_end_matches('\\')
1393+
.to_string();
1394+
let escaped = target.replace('\'', "''");
1395+
1396+
let script = format!(
1397+
r#"
1398+
$target = '{target}'
1399+
$normalizedTarget = $target.ToLowerInvariant().TrimEnd('\')
1400+
$existing = [Environment]::GetEnvironmentVariable('Path', 'User')
1401+
$parts = @()
1402+
1403+
if ($existing) {{
1404+
$parts = $existing -split ';' | Where-Object {{
1405+
$_ -and ($_.Trim() -ne '')
1406+
}} | ForEach-Object {{
1407+
$_.Trim()
1408+
}} | Where-Object {{
1409+
($_.ToLowerInvariant().Replace('/','\').TrimEnd('\')) -ne $normalizedTarget
1410+
}}
1411+
}}
1412+
1413+
$newParts = @($target) + $parts
1414+
$newPath = ($newParts -join ';').Trim(';')
1415+
[Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
1416+
"#,
1417+
target = escaped
1418+
);
1419+
1420+
let output = run_hidden_powershell(&script)?;
1421+
if output.status.success() {
1422+
Ok(())
1423+
} else {
1424+
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
1425+
Err(if stderr.is_empty() {
1426+
"Failed to update User PATH for Node.js".into()
1427+
} else {
1428+
stderr
1429+
})
1430+
}
1431+
}
1432+
#[cfg(not(target_os = "windows"))]
1433+
{
1434+
let _ = base_dir;
1435+
Err("Global Node PATH management is currently only supported on Windows".into())
1436+
}
1437+
}
1438+
13161439
#[tauri::command]
13171440
fn detect_install_base_dir() -> Result<Option<String>, String> {
13181441
detect_install_base_dir_internal()
@@ -2568,6 +2691,8 @@ pub fn run() {
25682691
list_node_versions,
25692692
activate_node_version,
25702693
deactivate_node_version,
2694+
get_node_path_status,
2695+
set_node_global_path,
25712696
detect_install_base_dir,
25722697
ensure_devstack_layout,
25732698
ensure_install_marker,

src-tauri/tauri.conf.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"$schema": "https://schema.tauri.app/config/2",
33
"productName": "DevStack",
4-
"version": "1.0.3",
4+
"version": "1.0.4",
55
"identifier": "com.devstack.desktop",
66
"build": {
77
"beforeDevCommand": "npm run dev",

src/components/PageNode.jsx

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,22 @@
11
import React, { useEffect, useRef, useState } from 'react';
22
import { ask } from '@tauri-apps/plugin-dialog';
3-
import { Boxes, Download, Loader, RefreshCw, Terminal, Trash2 } from 'lucide-react';
3+
import { Boxes, Download, Loader, RefreshCw, Trash2, Globe } from 'lucide-react';
44
import { useStore } from '../store';
55

66
const PageNode = () => {
77
const {
88
nodeVersions,
99
nodeInstallLogs,
1010
nodeInstallProgress,
11+
nodePathStatus,
1112
activatingNode,
1213
settings,
1314
scanInstalledNode,
1415
installNodeVersion,
1516
setActiveNode,
1617
uninstallNodeVersion,
17-
openNodeTerminal,
18+
setNodeGlobalPath,
19+
refreshNodePathStatus,
1820
showToast,
1921
t,
2022
} = useStore();
@@ -56,16 +58,35 @@ const PageNode = () => {
5658
>
5759
<RefreshCw size={14} /> {t('refresh')}
5860
</button>
59-
60-
<button
61-
className="btn-primary flex items-center gap-2 px-4 py-2 text-[12px] h-[42px]"
62-
onClick={() => openNodeTerminal()}
63-
>
64-
<Terminal size={14} /> {t('openNodeTerminal')}
65-
</button>
6661
</div>
6762

6863
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-5">
64+
<div className={`border rounded-[28px] p-6 ${nodePathStatus.devstackFirst ? 'bg-accent/5 border-accent/30' : 'bg-warn/5 border-warn/30'}`}>
65+
<div className="flex items-start justify-between gap-4 flex-wrap">
66+
<div className="flex-1 min-w-[320px]">
67+
<div className="text-[13px] font-bold uppercase tracking-wider flex items-center gap-2">
68+
<Globe size={14} className={nodePathStatus.devstackFirst ? 'text-accent' : 'text-warn'} />
69+
{t('nodeGlobalPriority')}
70+
</div>
71+
<div className="text-[12px] text-textDim mt-2">
72+
{nodePathStatus.devstackFirst ? t('nodeGlobalPriorityOk') : t('nodeGlobalPriorityHint')}
73+
</div>
74+
<div className="mt-3 text-[11px] text-muted font-mono break-all">
75+
{nodePathStatus.currentNodePath || t('nodeCurrentPathUnknown')}
76+
</div>
77+
</div>
78+
79+
<div className="flex items-center gap-2">
80+
<button className="btn-ghost flex items-center gap-2" onClick={refreshNodePathStatus}>
81+
<RefreshCw size={14} /> {t('refresh')}
82+
</button>
83+
<button className="btn-primary flex items-center gap-2" onClick={setNodeGlobalPath}>
84+
<Globe size={14} /> {t('nodeUseGlobally')}
85+
</button>
86+
</div>
87+
</div>
88+
</div>
89+
6990
<div className="bg-surface border border-border/40 shadow-liquid rounded-[28px] p-6">
7091
<div className="flex items-center justify-between gap-4 flex-wrap">
7192
<div>

src/i18n.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,13 @@ const translations = {
116116
nodeUninstalledToast: 'Node.js {version} uninstalled.',
117117
nodeUninstallFailed: 'Could not uninstall Node.js: {error}',
118118
uninstallNodeConfirm: 'Uninstall Node.js {version}?',
119+
nodeGlobalPriority: 'Global Node priority',
120+
nodeGlobalPriorityOk: 'New terminals already resolve Node.js from DevStack first.',
121+
nodeGlobalPriorityHint: 'Your machine is still resolving Node.js from another install. Put DevStack Node at the top of your user PATH to make it the default everywhere.',
122+
nodeUseGlobally: 'Use globally',
123+
nodeGlobalPathUpdated: 'DevStack Node was added to the top of your user PATH. Open a new terminal to apply it.',
124+
nodeGlobalPathFailed: 'Could not update Node PATH: {error}',
125+
nodeCurrentPathUnknown: 'Node.js path has not been resolved yet.',
119126

120127
// Tunnels
121128
tunnelsTitle: 'Tunnels',
@@ -522,6 +529,13 @@ const translations = {
522529
nodeUninstalledToast: 'Đã gỡ Node.js {version}.',
523530
nodeUninstallFailed: 'Không thể gỡ Node.js: {error}',
524531
uninstallNodeConfirm: 'Gỡ cài đặt Node.js {version}?',
532+
nodeGlobalPriority: 'Ưu tiên Node toàn cục',
533+
nodeGlobalPriorityOk: 'Các terminal mới đã ưu tiên Node.js từ DevStack.',
534+
nodeGlobalPriorityHint: 'Máy bạn vẫn đang resolve Node.js từ bản cài khác. Hãy đưa DevStack Node lên đầu User PATH để nó thành mặc định ở mọi terminal mới.',
535+
nodeUseGlobally: 'Dùng toàn cục',
536+
nodeGlobalPathUpdated: 'Đã đưa DevStack Node lên đầu User PATH. Hãy mở terminal mới để áp dụng.',
537+
nodeGlobalPathFailed: 'Không thể cập nhật PATH cho Node: {error}',
538+
nodeCurrentPathUnknown: 'Chưa xác định được đường dẫn Node.js hiện tại.',
525539

526540
// Tunnels
527541
tunnelsTitle: 'Đường hầm',

src/store/nodeSlice.js

Lines changed: 34 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ export const createNodeSlice = (set, get) => ({
33
nodeInstallLogs: [],
44
nodeInstallProgress: { pct: 0, downloaded: 0, total: 0 },
55
activatingNode: null,
6+
nodePathStatus: {
7+
currentNodePath: '',
8+
allNodePaths: [],
9+
devstackFirst: false,
10+
userPathContainsDevstack: false,
11+
},
612

713
normalizeNodeTag: (tag) => {
814
const normalized = `${tag || ''}`.trim().replace(/^v/i, '');
@@ -19,11 +25,30 @@ export const createNodeSlice = (set, get) => ({
1925
const normalized = Array.isArray(versions) ? versions : [];
2026
normalized.sort((a, b) => b.version.localeCompare(a.version, undefined, { numeric: true, sensitivity: 'base' }));
2127
set({ nodeVersions: normalized });
28+
await get().refreshNodePathStatus();
2229
} catch (e) {
2330
console.error('scanInstalledNode failed:', e);
2431
}
2532
},
2633

34+
refreshNodePathStatus: async () => {
35+
try {
36+
const { invoke } = await import('@tauri-apps/api/core');
37+
const baseDir = get().settings.devStackDir.replace(/[\\\/]+$/, '');
38+
const status = await invoke('get_node_path_status', { baseDir });
39+
set({
40+
nodePathStatus: {
41+
currentNodePath: status.current_node_path || '',
42+
allNodePaths: status.all_node_paths || [],
43+
devstackFirst: !!status.devstack_first,
44+
userPathContainsDevstack: !!status.user_path_contains_devstack,
45+
}
46+
});
47+
} catch (e) {
48+
console.error('refreshNodePathStatus failed:', e);
49+
}
50+
},
51+
2752
installNodeVersion: async (tag) => {
2853
const version = get().normalizeNodeTag(tag);
2954
if (!version) {
@@ -117,6 +142,7 @@ export const createNodeSlice = (set, get) => ({
117142
const baseDir = get().settings.devStackDir.replace(/[\\\/]+$/, '');
118143
await invoke('activate_node_version', { baseDir, version });
119144
await get().scanInstalledNode();
145+
await get().refreshNodePathStatus();
120146
if (!options.silent) {
121147
get().showToast(get().t('nodeActivatedToast', { version }), 'ok');
122148
}
@@ -146,32 +172,24 @@ export const createNodeSlice = (set, get) => ({
146172
}
147173
await invoke('remove_dir', { path: targetDir.replace(/\//g, '\\') });
148174
await get().scanInstalledNode();
175+
await get().refreshNodePathStatus();
149176
get().showToast(get().t('nodeUninstalledToast', { version }), 'warn');
150177
} catch (e) {
151178
console.error('uninstallNodeVersion failed:', e);
152179
get().showToast(get().t('nodeUninstallFailed', { error: `${e}` }), 'danger');
153180
}
154181
},
155182

156-
openNodeTerminal: async (prjPath = '') => {
157-
const activeNode = get().nodeVersions.find(v => v.active);
158-
if (!activeNode) {
159-
get().showToast(get().t('nodeNoActiveVersion'), 'warn');
160-
return;
161-
}
162-
163-
const targetPath = (prjPath || get().settings.rootPath || get().settings.devStackDir || 'C:/devstack').replace(/\//g, '\\');
164-
const currentNodePath = `${get().settings.devStackDir.replace(/\//g, '\\')}\\bin\\node\\current`;
165-
183+
setNodeGlobalPath: async () => {
166184
try {
167185
const { invoke } = await import('@tauri-apps/api/core');
168-
await invoke('start_detached_process', {
169-
executable: 'cmd.exe',
170-
args: ['/C', 'start', 'cmd.exe', '/K', `set "PATH=${currentNodePath};%PATH%" && cd /d "${targetPath}" && title DevStack Node v${activeNode.version}`]
171-
});
186+
const baseDir = get().settings.devStackDir.replace(/[\\\/]+$/, '');
187+
await invoke('set_node_global_path', { baseDir });
188+
await get().refreshNodePathStatus();
189+
get().showToast(get().t('nodeGlobalPathUpdated'), 'ok');
172190
} catch (e) {
173-
console.error('openNodeTerminal failed:', e);
174-
get().showToast(get().t('nodeTerminalFailed', { error: `${e}` }), 'danger');
191+
console.error('setNodeGlobalPath failed:', e);
192+
get().showToast(get().t('nodeGlobalPathFailed', { error: `${e}` }), 'danger');
175193
}
176194
},
177195
});

src/store/serviceSlice.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -441,7 +441,7 @@ export const createServiceSlice = (set, get) => ({
441441
// but start it detached.
442442
await invoke('start_detached_process', {
443443
executable: 'cmd.exe',
444-
args: ['/C', 'start', 'cmd.exe', '/K', `${nodePathPrefix}cd /d "${targetPath}" && title DevStack Terminal`]
444+
args: ['/C', 'start', '""', 'cmd.exe', '/K', `title DevStack Terminal && ${nodePathPrefix}cd /d "${targetPath}"`]
445445
});
446446
},
447447

0 commit comments

Comments
 (0)