diff --git a/.github/workflows/build-vsix.yml b/.github/workflows/build-vsix.yml
new file mode 100644
index 0000000..fd9b441
--- /dev/null
+++ b/.github/workflows/build-vsix.yml
@@ -0,0 +1,91 @@
+name: Publish VSIX
+
+on:
+ push:
+ tags:
+ - v*
+
+jobs:
+ build:
+ name: Build VSIX
+ runs-on: windows-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+ - name: Setup MSBuild (add to PATH)
+ uses: microsoft/setup-msbuild@v2
+ - name: Locate MSBuild
+ id: msbuild
+ shell: pwsh
+ run: |
+ $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe"
+ if(-not (Test-Path $vswhere)) { throw "vswhere not found at $vswhere" }
+ $msbuild = & $vswhere -latest -requires Microsoft.Component.MSBuild -find "MSBuild\**\Bin\MSBuild.exe" | Select-Object -First 1
+ if(-not $msbuild){ throw 'MSBuild not found' }
+ "path=$msbuild" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
+ - name: Restore (solution)
+ shell: pwsh
+ run: |
+ dotnet restore src/CodeIndex.VisualStudioExtension/CodeIndex.VisualStudioExtension.sln
+ - name: Build VSIX (Release)
+ shell: pwsh
+ run: |
+ & '${{ steps.msbuild.outputs.path }}' src/CodeIndex.VisualStudioExtension/CodeIndex.VisualStudioExtension.csproj /t:Build /p:Configuration=Release /nologo
+ - name: Locate VSIX
+ id: locate_vsix
+ shell: pwsh
+ run: |
+ $outDir = "src/CodeIndex.VisualStudioExtension/bin/Release"
+ if(-not (Test-Path $outDir)) { throw "Output directory not found: $outDir" }
+ Write-Host "Listing VSIX output directory contents:"; Get-ChildItem -Path $outDir | Format-Table Name,Length,LastWriteTime
+ $vsix = Get-ChildItem -Path $outDir -Filter *.vsix -File | Sort-Object LastWriteTime -Descending | Select-Object -First 1
+ if(-not $vsix) {
+ Write-Host 'No VSIX found; dumping recursive tree:'
+ Get-ChildItem -Path $outDir -Recurse | Select-Object FullName,Length,LastWriteTime | Format-Table -AutoSize
+ throw 'VSIX file not generated.'
+ }
+ Write-Host "Found VSIX: $($vsix.FullName)"
+ if(-not (Test-Path 'artifacts')) { New-Item -ItemType Directory -Path artifacts | Out-Null }
+ $dest = Join-Path -Path (Resolve-Path 'artifacts').Path -ChildPath $vsix.Name
+ Copy-Item -Path $vsix.FullName -Destination $dest -Force -ErrorAction Stop
+ Write-Host "Copied to $dest"
+ "path=$dest" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8
+ - name: Upload artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: CodeIndex.VSIX
+ path: ${{ steps.locate_vsix.outputs.path }}
+ if-no-files-found: error
+
+ release:
+ name: Create / Update Release
+ needs: build
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ steps:
+ - name: Download artifact
+ uses: actions/download-artifact@v4
+ with:
+ name: CodeIndex.VSIX
+ path: downloaded
+ - name: List downloaded files
+ run: ls -R downloaded
+ - name: Create or update release
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ shell: bash
+ run: |
+ tag="$GITHUB_REF_NAME"
+ asset=$(ls downloaded/*.vsix | head -n1)
+ if [ -z "$asset" ]; then
+ echo "No VSIX asset found" >&2
+ exit 1
+ fi
+ echo "Publishing release $tag with asset $asset"
+ if gh release create "$tag" "$asset" --title "VSIX $tag" --notes "VSIX build for $tag" -R "$GITHUB_REPOSITORY"; then
+ echo "Release created"
+ else
+ echo "Release exists, updating asset..."
+ gh release upload "$tag" "$asset" --clobber -R "$GITHUB_REPOSITORY"
+ fi
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 9f7b8c9..f51c37b 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -46,9 +46,9 @@ jobs:
Write-Host "Tag $tag deleted to prevent release."
build:
- name: Build & Package (framework-dependent any-platform)
+ name: Build & Package
needs: test
- runs-on: ubuntu-latest
+ runs-on: windows-latest
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -67,15 +67,16 @@ jobs:
-p:PublishSingleFile=false -p:SelfContained=false -o artifacts/publish-any
- name: Package zip
+ shell: pwsh
run: |
- mkdir -p artifacts/zips
- (cd artifacts/publish-any && zip -r ../zips/CodeIndex.Server-any.zip .)
+ New-Item -ItemType Directory -Path artifacts/zips -Force | Out-Null
+ Compress-Archive -Path artifacts/publish-any/* -DestinationPath artifacts/zips/CodeIndex.Server.zip -Force
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
- name: CodeIndex.Server-any
- path: artifacts/zips/CodeIndex.Server-any.zip
+ name: CodeIndex.Server
+ path: artifacts/zips/CodeIndex.Server.zip
if-no-files-found: error
release:
@@ -88,7 +89,7 @@ jobs:
- name: Download artifact
uses: actions/download-artifact@v4
with:
- name: CodeIndex.Server-any
+ name: CodeIndex.Server
path: downloaded
- name: List downloaded files
@@ -100,9 +101,9 @@ jobs:
shell: bash
run: |
tag="$GITHUB_REF_NAME"
- asset="downloaded/CodeIndex.Server-any.zip"
- echo "Publishing release $tag with asset $asset (framework-dependent build)"
- if gh release create "$tag" "$asset" --title "CodeIndex.Server $tag" --notes "Framework-dependent (cross-platform) build for $tag" -R "$GITHUB_REPOSITORY"; then
+ asset="downloaded/CodeIndex.Server.zip"
+ echo "Publishing release $tag with asset $asset"
+ if gh release create "$tag" "$asset" --title "CodeIndex.Server $tag" --notes "Framework-dependent build (.exe) for $tag" -R "$GITHUB_REPOSITORY"; then
echo "Release created"
else
echo "Release exists, updating asset..."
diff --git a/README.md b/README.md
index 594a7ea..0a34dfa 100644
--- a/README.md
+++ b/README.md
@@ -93,11 +93,53 @@ Notice: in the docker container, when add the index config, the monitor folder s
### Search Extension For Visual Studio
-|Status|Value|
+Current icon used in listing:
+
+
+
+|Status|Badge|
|:----|:---:|
-|VS Marketplace|[](https://marketplace.visualstudio.com/items?itemName=qiuhaotc.CodeIndexExtension)
-|VS Marketplace Downloads|[](https://marketplace.visualstudio.com/items?itemName=qiuhaotc.CodeIndexExtension)
-|VS Marketplace Installs|[](https://marketplace.visualstudio.com/items?itemName=qiuhaotc.CodeIndexExtension)
+|VS Marketplace Version|[](https://marketplace.visualstudio.com/items?itemName=qiuhaotc.CodeIndexExtension)|
+|VS Marketplace Downloads|[](https://marketplace.visualstudio.com/items?itemName=qiuhaotc.CodeIndexExtension)|
+|VS Marketplace Installs|[](https://marketplace.visualstudio.com/items?itemName=qiuhaotc.CodeIndexExtension)|
+|VS Marketplace Rating|[](https://marketplace.visualstudio.com/items?itemName=qiuhaotc.CodeIndexExtension)|
+
+#### Recent Updates
+
+Latest improvements to the Visual Studio extension
+
+- Dual Server Modes
+ - Seamlessly switch between Local and Remote server modes with persistent settings.
+ - Automatic health probe on settings window open (local mode) + manual Check button.
+- Resilient Local Server Lifecycle
+ - Single-instance control (global mutex + per-process lock files + PID file).
+ - Auto restart if external termination detected (mutex loss recovery).
+ - Deferred index loading until server health passes.
+- Download / Update Workflow
+ - One-click Download / Update (latest tag scraped from Releases page).
+ - Streamed download with real-time percentage progress.
+ - Temp ZIP cleanup after successful extraction; validates core binary presence.
+- Smart Default Paths
+ - Auto install path: `%LOCALAPPDATA%/CodeIndex.VisualStudioExtension/CodeIndex.Server` when empty.
+ - Auto data path: `%LOCALAPPDATA%/CodeIndex.VisualStudioExtension/CodeIndex.Server.Data` when first selecting install path and data path empty.
+- Modern Folder Picker
+ - Replaced WinForms dialog with Vista IFileOpenDialog (better UX); removed System.Windows.Forms dependency.
+- Theme-Aware UI
+ - Buttons/styles now use Visual Studio dynamic theme brushes (light/dark/HC) instead of hardcoded colors.
+- Quick Navigation Buttons
+ - Open buttons beside Local & Remote URLs (auto prepend http:// when missing).
+- Responsive Async Commands
+ - Instant button enable/disable; removed unsafe async void patterns.
+- Embedded Log Viewer
+ - Displays latest 100 log lines with refresh.
+- Packaging & Manifest Reliability
+ - Pre-build sync of `source.extension.vsixmanifest` prevents stale version drift.
+ - Architecture targeting + ProductArchitecture resolves VSSDK1311 warning.
+- Settings & Migration
+ - JSON settings, legacy URL migration, normalized trailing slashes.
+- Additional Hardening
+ - Clear health states (Started / Stopped / Error / Unknown) drive UI state.
+ - Improved error messages for download / extraction / URL opening.
#### Download Url
@@ -127,13 +169,19 @@ When Phase Quuery not been ticked, you can follow the sytax under [http://www.lu
When Case-Sensitive been ticked, we can search the content in case-sensitive mode. When search the content like String, it won't return the content that contains string
+## Extension Compile
+
+```powershell
+& "C:\Program Files\Microsoft Visual Studio\18\Insiders\MSBuild\Current\Bin\MSBuild.exe" src\CodeIndex.VisualStudioExtension\CodeIndex.VisualStudioExtension.csproj /t:Build /p:Configuration=Debug /nologo
+```
+
## Misc
|Status|Value|
|:----|:---:|
-|Stars|[](https://github.com/qiuhaotc/CodeIndex)
-|Forks|[](https://github.com/qiuhaotc/CodeIndex)
-|License|[](https://github.com/qiuhaotc/CodeIndex)
-|Issues|[](https://github.com/qiuhaotc/CodeIndex)
-|Docker Pulls|[](https://hub.docker.com/r/qiuhaotc/codeindex)
-|Release Downloads|[](https://github.com/qiuhaotc/CodeIndex/releases)
+|Stars|[](https://github.com/qiuhaotc/CodeIndex)|
+|Forks|[](https://github.com/qiuhaotc/CodeIndex)|
+|License|[](https://github.com/qiuhaotc/CodeIndex)|
+|Issues|[](https://github.com/qiuhaotc/CodeIndex)|
+|Docker Pulls|[](https://hub.docker.com/r/qiuhaotc/codeindex)|
+|Release Downloads|[](https://github.com/qiuhaotc/CodeIndex/releases)|
diff --git a/doc/UseExtension.gif b/doc/UseExtension.gif
index 0a5f914..ed8a4b1 100644
Binary files a/doc/UseExtension.gif and b/doc/UseExtension.gif differ
diff --git a/src/.vscode/launch.json b/src/.vscode/launch.json
deleted file mode 100644
index 9b523bf..0000000
--- a/src/.vscode/launch.json
+++ /dev/null
@@ -1,22 +0,0 @@
-{
- "version": "0.2.0",
- "configurations": [
- {
- "name": ".NET Core Launch (console)",
- "type": "coreclr",
- "request": "launch",
- "preLaunchTask": "build",
- "program": "${workspaceFolder}/CodeIndex.ConsoleApp/bin/Debug/netcoreapp3.1/CodeIndex.ConsoleApp.dll",
- "args": [],
- "cwd": "${workspaceFolder}/CodeIndex.ConsoleApp",
- "console": "internalConsole",
- "stopAtEntry": false
- },
- {
- "name": ".NET Core Attach",
- "type": "coreclr",
- "request": "attach",
- "processId": "${command:pickProcess}"
- }
- ]
-}
\ No newline at end of file
diff --git a/src/.vscode/tasks.json b/src/.vscode/tasks.json
deleted file mode 100644
index 3966d7a..0000000
--- a/src/.vscode/tasks.json
+++ /dev/null
@@ -1,42 +0,0 @@
-{
- "version": "2.0.0",
- "tasks": [
- {
- "label": "build",
- "command": "dotnet",
- "type": "process",
- "args": [
- "build",
- "${workspaceFolder}/CodeIndex.ConsoleApp/CodeIndex.ConsoleApp.csproj",
- "/property:GenerateFullPaths=true",
- "/consoleloggerparameters:NoSummary"
- ],
- "problemMatcher": "$msCompile"
- },
- {
- "label": "publish",
- "command": "dotnet",
- "type": "process",
- "args": [
- "publish",
- "${workspaceFolder}/CodeIndex.ConsoleApp/CodeIndex.ConsoleApp.csproj",
- "/property:GenerateFullPaths=true",
- "/consoleloggerparameters:NoSummary"
- ],
- "problemMatcher": "$msCompile"
- },
- {
- "label": "watch",
- "command": "dotnet",
- "type": "process",
- "args": [
- "watch",
- "run",
- "${workspaceFolder}/CodeIndex.ConsoleApp/CodeIndex.ConsoleApp.csproj",
- "/property:GenerateFullPaths=true",
- "/consoleloggerparameters:NoSummary"
- ],
- "problemMatcher": "$msCompile"
- }
- ]
-}
\ No newline at end of file
diff --git a/src/CodeIndex.VisualStudioExtension/CodeIndex.VisualStudioExtension.csproj b/src/CodeIndex.VisualStudioExtension/CodeIndex.VisualStudioExtension.csproj
index 51148ba..c001715 100644
--- a/src/CodeIndex.VisualStudioExtension/CodeIndex.VisualStudioExtension.csproj
+++ b/src/CodeIndex.VisualStudioExtension/CodeIndex.VisualStudioExtension.csproj
@@ -9,15 +9,14 @@
false
- bin\Release\
- true
- TRACE
- prompt
- pdbonly
- true
- true
- true
-
+ bin\Release\
+ true
+ TRACE
+ prompt
+ pdbonly
+ true
+ true
+
Debug
AnyCPU
@@ -121,10 +120,42 @@
Designer
MSBuild:Compile
+
+ Designer
+ MSBuild:Compile
+
+
+
+ $(NoWarn);MSB4011
+
+
+
+
+
+ <_CodeIndexManifest Include="source.extension.vsixmanifest" Condition="Exists('source.extension.vsixmanifest')" />
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/CodeIndex.VisualStudioExtension/CodeIndex.VisualStudioExtensionPackage.cs b/src/CodeIndex.VisualStudioExtension/CodeIndex.VisualStudioExtensionPackage.cs
index 53f4c7b..d38a3f5 100644
--- a/src/CodeIndex.VisualStudioExtension/CodeIndex.VisualStudioExtensionPackage.cs
+++ b/src/CodeIndex.VisualStudioExtension/CodeIndex.VisualStudioExtensionPackage.cs
@@ -43,12 +43,31 @@ public sealed class VisualStudioExtensionPackage : AsyncPackage
/// A cancellation token to monitor for initialization cancellation, which can occur when VS is shutting down.
/// A provider for progress updates.
/// A task representing the async work of package initialization, or an already completed task if there is none. Do not return null from this method.
+ UserSettings loadedSettings;
+
protected override async Task InitializeAsync(CancellationToken cancellationToken, IProgress progress)
{
- // When initialized asynchronously, the current thread may be a background thread at this point.
- // Do any initialization that requires the UI thread after switching to the UI thread.
- await this.JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken);
- await CodeIndex.VisualStudioExtension.CodeIndexSearchWindowCommand.InitializeAsync(this);
+ // 后台线程:可以做不需要 UI 的初始化
+ loadedSettings = UserSettingsManager.Load();
+ // 注册实例(引用 + 确保启动)
+ await Models.LocalServerLauncher.RegisterInstanceAsync(loadedSettings);
+
+ // 切换到 UI 线程再注册命令
+ await JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken);
+ await CodeIndexSearchWindowCommand.InitializeAsync(this);
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ try
+ {
+ if (disposing && loadedSettings != null)
+ {
+ Models.LocalServerLauncher.UnregisterInstance(loadedSettings);
+ }
+ }
+ catch { }
+ base.Dispose(disposing);
}
#endregion
diff --git a/src/CodeIndex.VisualStudioExtension/CodeIndexSearchWindowCommand.cs b/src/CodeIndex.VisualStudioExtension/CodeIndexSearchWindowCommand.cs
index cba22a4..db01c9c 100644
--- a/src/CodeIndex.VisualStudioExtension/CodeIndexSearchWindowCommand.cs
+++ b/src/CodeIndex.VisualStudioExtension/CodeIndexSearchWindowCommand.cs
@@ -13,7 +13,7 @@ internal sealed class CodeIndexSearchWindowCommand
///
/// Command ID.
///
- public const int CommandId = 4129;
+ public const int CommandId = 4129;
///
/// Command menu group (command set GUID).
@@ -39,6 +39,7 @@ private CodeIndexSearchWindowCommand(AsyncPackage package, OleMenuCommandService
var menuCommandID = new CommandID(CommandSet, CommandId);
var menuItem = new MenuCommand(this.Execute, menuCommandID);
commandService.AddCommand(menuItem);
+
}
///
@@ -84,12 +85,14 @@ private void Execute(object sender, EventArgs e)
{
this.package.JoinableTaskFactory.RunAsync(async delegate
{
+ await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync();
ToolWindowPane window = await this.package.ShowToolWindowAsync(typeof(CodeIndexSearchWindow), 0, true, this.package.DisposalToken);
- if ((null == window) || (null == window.Frame))
+ if (window?.Frame == null)
{
throw new NotSupportedException("Cannot create tool window");
}
- });
+ }).FileAndForget("CodeIndex/ShowToolWindow");
}
+
}
}
diff --git a/src/CodeIndex.VisualStudioExtension/Controls/CodeIndexSearchControl.xaml b/src/CodeIndex.VisualStudioExtension/Controls/CodeIndexSearchControl.xaml
index 1167312..7645863 100644
--- a/src/CodeIndex.VisualStudioExtension/Controls/CodeIndexSearchControl.xaml
+++ b/src/CodeIndex.VisualStudioExtension/Controls/CodeIndexSearchControl.xaml
@@ -3,10 +3,18 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
+ xmlns:vsshell="clr-namespace:Microsoft.VisualStudio.Shell;assembly=Microsoft.VisualStudio.Shell.15.0"
mc:Ignorable="d"
- d:DesignHeight="281.2" d:DesignWidth="987.2" Foreground="Black" Background="#FF2D2D30">
+ d:DesignHeight="281.2" d:DesignWidth="987.2"
+ Foreground="{DynamicResource {x:Static vsshell:VsBrushes.WindowTextKey}}"
+ Background="{DynamicResource {x:Static vsshell:VsBrushes.ToolWindowBackgroundKey}}">
-
+
+
+
+
+
+
@@ -44,18 +52,17 @@
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
@@ -64,13 +71,13 @@
-
+
-
+
diff --git a/src/CodeIndex.VisualStudioExtension/Controls/CodeIndexSearchControl.xaml.cs b/src/CodeIndex.VisualStudioExtension/Controls/CodeIndexSearchControl.xaml.cs
index 8f48d0d..77b1d12 100644
--- a/src/CodeIndex.VisualStudioExtension/Controls/CodeIndexSearchControl.xaml.cs
+++ b/src/CodeIndex.VisualStudioExtension/Controls/CodeIndexSearchControl.xaml.cs
@@ -22,11 +22,16 @@ void ContentTextBox_KeyUp(object sender, KeyEventArgs e)
if (e.Key == Key.Enter)
{
TextBox_KeyDown(sender, e);
+ return;
}
- else
+
+ if (SearchViewModel == null)
{
- SearchViewModel?.GetHintWordsAsync();
+ return;
}
+
+ // 调度提示词获取(由 ViewModel 内部使用 JoinableTaskCollection 追踪,避免 VSSDK007 警告)
+ SearchViewModel.ScheduleGetHintWords();
}
void TextBox_KeyDown(object sender, KeyEventArgs e)
diff --git a/src/CodeIndex.VisualStudioExtension/Controls/SettingsWindow.xaml b/src/CodeIndex.VisualStudioExtension/Controls/SettingsWindow.xaml
new file mode 100644
index 0000000..94a7464
--- /dev/null
+++ b/src/CodeIndex.VisualStudioExtension/Controls/SettingsWindow.xaml
@@ -0,0 +1,108 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/CodeIndex.VisualStudioExtension/Controls/SettingsWindow.xaml.cs b/src/CodeIndex.VisualStudioExtension/Controls/SettingsWindow.xaml.cs
new file mode 100644
index 0000000..2bf66fb
--- /dev/null
+++ b/src/CodeIndex.VisualStudioExtension/Controls/SettingsWindow.xaml.cs
@@ -0,0 +1,14 @@
+using System.Windows;
+using CodeIndex.VisualStudioExtension.Models;
+
+namespace CodeIndex.VisualStudioExtension.Controls
+{
+ public partial class SettingsWindow : Window
+ {
+ public SettingsWindow(SettingsViewModel vm)
+ {
+ InitializeComponent();
+ DataContext = vm;
+ }
+ }
+}
diff --git a/src/CodeIndex.VisualStudioExtension/Models/BaseViewModel.cs b/src/CodeIndex.VisualStudioExtension/Models/BaseViewModel.cs
index 1d143a6..8df0110 100644
--- a/src/CodeIndex.VisualStudioExtension/Models/BaseViewModel.cs
+++ b/src/CodeIndex.VisualStudioExtension/Models/BaseViewModel.cs
@@ -1,7 +1,8 @@
using System;
using System.ComponentModel;
using System.Runtime.CompilerServices;
-using System.Windows.Threading;
+using System.Threading.Tasks;
+using Microsoft.VisualStudio.Shell;
namespace CodeIndex.VisualStudioExtension
{
@@ -19,9 +20,14 @@ public void NotifyPropertyChange(Func propertyName)
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName.Invoke()));
}
- public static void InvokeDispatcher(Action action, Dispatcher dispatcher, DispatcherPriority dispatcherPriority = DispatcherPriority.Normal)
+ ///
+ /// 在 UI 线程执行委托(如果可能)。如果当前不在 UI 线程,会切换。
+ ///
+ public static async Task InvokeOnUIThreadAsync(Action action)
{
- dispatcher?.BeginInvoke(dispatcherPriority, action);
+ if (action == null) return;
+ await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync();
+ action();
}
}
}
diff --git a/src/CodeIndex.VisualStudioExtension/Models/CodeIndexSearchViewModel.cs b/src/CodeIndex.VisualStudioExtension/Models/CodeIndexSearchViewModel.cs
index b00ddae..574ff02 100644
--- a/src/CodeIndex.VisualStudioExtension/Models/CodeIndexSearchViewModel.cs
+++ b/src/CodeIndex.VisualStudioExtension/Models/CodeIndexSearchViewModel.cs
@@ -5,6 +5,8 @@
using System.Threading.Tasks;
using System.Net.Http;
using System.Windows.Input;
+using Microsoft.VisualStudio.Shell;
+using Microsoft.VisualStudio.Threading;
using CodeIndex.VisualStudioExtension.Models;
namespace CodeIndex.VisualStudioExtension
@@ -13,8 +15,13 @@ public class CodeIndexSearchViewModel : BaseViewModel
{
public CodeIndexSearchViewModel()
{
- serviceUrl = ConfigHelper.Configuration.AppSettings.Settings[nameof(ServiceUrl)].Value;
- _ = LoadIndexInfosAsync();
+ userSettings = UserSettingsManager.Load();
+ serviceUrl = userSettings.Mode == ServerMode.Local ? userSettings.LocalServiceUrl : userSettings.RemoteServiceUrl;
+
+ // 使用 VS 提供的 ThreadHelper.JoinableTaskFactory,避免 VSSDK005(不要自建 JoinableTaskContext)
+ jtf = ThreadHelper.JoinableTaskFactory;
+ trackedTasks = jtf.Context.CreateCollection();
+ trackedTasks.Add(jtf.RunAsync(LoadIndexInfosAsync)); // 加入集合以便被视为“已观察”从而避免 VSSDK007
}
public Guid IndexPk
@@ -33,25 +40,12 @@ public Guid IndexPk
public string FileLocation { get; set; }
public int ShowResultsNumber { get; set; } = 1000;
- public string ServiceUrl
- {
- get => serviceUrl;
- set
- {
- if (value != null && value.EndsWith("/"))
- {
- value = value.Substring(0, value.Length - 1);
- }
-
- if (value != serviceUrl)
- {
- ConfigHelper.SetConfiguration(nameof(ServiceUrl), value);
- serviceUrl = value;
- }
+ // 兼容旧代码:保留 ServiceUrl 字段用于外部引用(如打开详情页面),但不再直接绑定 UI
+ public string ServiceUrl => EffectiveServiceUrl;
- _ = LoadIndexInfosAsync();
- }
- }
+ string EffectiveServiceUrl => userSettings.Mode == ServerMode.Local
+ ? (userSettings.LocalServiceUrl?.TrimEnd('/') ?? serviceUrl)
+ : (userSettings.RemoteServiceUrl?.TrimEnd('/') ?? serviceUrl);
public bool CaseSensitive { get; set; }
@@ -66,8 +60,24 @@ async Task LoadIndexInfosAsync()
tokenToLoadIndexInfos?.Cancel();
tokenToLoadIndexInfos?.Dispose();
tokenToLoadIndexInfos = new CancellationTokenSource();
-
- var client = new CodeIndexClient(new HttpClient(), ServiceUrl);
+ // 若为本地模式且服务器未启动,先尝试启动并等待健康
+ if (userSettings.Mode == ServerMode.Local)
+ {
+ var ensure = await LocalServerLauncher.EnsureServerRunningAsync(userSettings, tokenToLoadIndexInfos.Token);
+ if (!ensure)
+ {
+ ResultInfo = "Local server not started.";
+ return;
+ }
+ else
+ {
+ // 本地服务器刚刚成功启动,强制刷新相关绑定(例如 ServiceUrl 显示)
+ NotifyPropertyChange(nameof(ServiceUrl));
+ ResultInfo = "Local server started, loading indexes...";
+ }
+ // 再次刷新 EffectiveServiceUrl 以防用户修改
+ }
+ var client = new CodeIndexClient(new HttpClient(), EffectiveServiceUrl);
var result = await client.ApiLuceneGetindexviewlistAsync(tokenToLoadIndexInfos.Token);
IndexInfos = result.Status.Success ? result.Result.Select(u => new Item(u.IndexName, u.Pk)).ToList() : IndexInfos;
@@ -109,7 +119,7 @@ public async Task GetHintWordsAsync()
{
try
{
- var client = new CodeIndexClient(new HttpClient(), ServiceUrl);
+ var client = new CodeIndexClient(new HttpClient(), EffectiveServiceUrl);
var result = await client.ApiLuceneGethintsAsync(inputWord, IndexPk);
if (result.Status.Success)
@@ -189,7 +199,11 @@ public Item(string name, T value)
ICommand searchIndexCommand;
ICommand stopSearchCommand;
ICommand refreshIndexCommand;
- string serviceUrl;
+ ICommand openSettingsCommand;
+ string serviceUrl;
+ readonly UserSettings userSettings;
+ JoinableTaskCollection trackedTasks; // 跟踪后台任务集合
+ JoinableTaskFactory jtf; // VS 提供的 JoinableTaskFactory
List searchResults = new List();
string resultInfo;
CancellationTokenSource tokenSource;
@@ -242,6 +256,44 @@ public ICommand RefreshIndexCommand
}
}
+ public ICommand OpenSettingsCommand
+ {
+ get
+ {
+ if (openSettingsCommand == null)
+ {
+ openSettingsCommand = new AsyncCommand(OpenSettingsAsync, () => true, null);
+ }
+ return openSettingsCommand;
+ }
+ }
+
+ async Task OpenSettingsAsync()
+ {
+ await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync();
+ try
+ {
+ var settings = UserSettingsManager.Load();
+ var vm = new SettingsViewModel(settings);
+ var window = new CodeIndex.VisualStudioExtension.Controls.SettingsWindow(vm)
+ {
+ Owner = System.Windows.Application.Current?.MainWindow
+ };
+ var result = window.ShowDialog();
+ if (result == true)
+ {
+ // 切换生效:重新计算有效 URL 并刷新索引
+ serviceUrl = vm.IsLocalMode ? settings.LocalServiceUrl : settings.RemoteServiceUrl;
+ trackedTasks?.Add(jtf.RunAsync(LoadIndexInfosAsync));
+ NotifyPropertyChange(nameof(ServiceUrl));
+ }
+ }
+ catch (Exception ex)
+ {
+ System.Windows.MessageBox.Show("Open settings failed: " + ex.Message, "CodeIndex", System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Error);
+ }
+ }
+
#region SearchCodeIndex
bool IsSearching { get; set; }
@@ -278,7 +330,7 @@ async Task SearchCodeIndexCoreAsync()
{
if (IsValidate())
{
- var client = new CodeIndexClient(new HttpClient(), ServiceUrl);
+ var client = new CodeIndexClient(new HttpClient(), EffectiveServiceUrl);
var result = await client.ApiLuceneGetcodesourceswithmatchedlineAsync(new SearchRequest
{
IndexPk = indexPk,
@@ -354,5 +406,28 @@ bool IsValidate()
}
#endregion
+
+ #region HintWords 调度封装
+ public void ScheduleGetHintWords()
+ {
+ if (string.IsNullOrEmpty(Content))
+ {
+ return;
+ }
+
+ // 将获取提示词操作作为后台任务加入集合(内部自行捕获异常)
+ trackedTasks?.Add(jtf.RunAsync(async delegate
+ {
+ try
+ {
+ await GetHintWordsAsync();
+ }
+ catch
+ {
+ // 忽略异常,提示词非关键路径
+ }
+ }));
+ }
+ #endregion
}
}
diff --git a/src/CodeIndex.VisualStudioExtension/Models/Commands.cs b/src/CodeIndex.VisualStudioExtension/Models/Commands.cs
index 861e7d4..4d28246 100644
--- a/src/CodeIndex.VisualStudioExtension/Models/Commands.cs
+++ b/src/CodeIndex.VisualStudioExtension/Models/Commands.cs
@@ -51,7 +51,7 @@ public AsyncCommand(
Func canExecute,
Action errorHandler)
{
- this.execute = execute;
+ this.execute = execute ?? throw new ArgumentNullException(nameof(execute));
this.canExecute = canExecute;
this.errorHandler = errorHandler;
}
@@ -89,6 +89,7 @@ public bool CanExecute(object parameter)
return CanExecute();
}
+ [System.Diagnostics.CodeAnalysis.SuppressMessage("Usage", "VSTHRD100", Justification = "ICommand.Execute 必须为 void;内部已捕获异常,且不需切回 UI 线程")]
public async void Execute(object parameter)
{
try
diff --git a/src/CodeIndex.VisualStudioExtension/Models/LocalServerLauncher.cs b/src/CodeIndex.VisualStudioExtension/Models/LocalServerLauncher.cs
new file mode 100644
index 0000000..40fca08
--- /dev/null
+++ b/src/CodeIndex.VisualStudioExtension/Models/LocalServerLauncher.cs
@@ -0,0 +1,445 @@
+using System;
+using System.Diagnostics;
+using System.IO;
+using System.Linq;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Text;
+
+namespace CodeIndex.VisualStudioExtension.Models
+{
+ internal static class LocalServerLauncher
+ {
+ static Mutex singleInstanceMutex; // 仅负责“我是否启动过”
+ const string MutexName = "Global_CodeIndex_Server_Singleton"; // 跨 VS 实例共享,表示“某 VS 已经负责启动”
+
+ static int localRefCount; // 当前进程(VS 实例)内引用数(窗口等)
+ static readonly HttpClient http = new HttpClient();
+
+ // 新的多进程引用保持:为每个 VS 进程创建单独锁文件 (clients/-.lock) 持续保持打开
+ // 关闭 / 崩溃:进程退出 OS 会释放句柄,后续清理逻辑会剔除僵尸文件(对应 pid 不存在)
+ const string ClientLocksFolderName = "clients";
+ const string GlobalClientsMutexName = "Global_CodeIndex_Server_ClientLocks"; // 跨进程协调清理/判断
+ static string clientLockFilePath;
+ static FileStream clientLockStream; // 保持打开以标识本 VS 进程存活
+
+ public static async Task RegisterInstanceAsync(UserSettings settings)
+ {
+ if (settings == null) return;
+ if (settings.Mode != ServerMode.Local) return;
+ try
+ {
+ var after = Interlocked.Increment(ref localRefCount);
+ if (after == 1)
+ {
+ EnsureClientLockFile(settings);
+ }
+
+ // 后台确保运行
+ await EnsureServerRunningAsync(settings, CancellationToken.None);
+ }
+ catch { }
+ }
+
+ public static void UnregisterInstance(UserSettings settings)
+ {
+ if (settings == null) return;
+ if (settings.Mode != ServerMode.Local) return;
+ try
+ {
+ var after = Interlocked.Decrement(ref localRefCount);
+ if (after < 0) localRefCount = 0;
+ if (after == 0)
+ {
+ // 尝试如果已经无其它 VS 客户端则停止
+ _ = StopServerIfLastAsync(settings);
+ }
+ }
+ catch { }
+ }
+
+ ///
+ /// 确保本地 Server 已运行(通过健康检查判断),若未运行则使用互斥启动。
+ ///
+ // 单实例内的去重 / 缓存:防止同一个 VS 进程里高频重复调用导致并发启动尝试
+ static readonly object ensureSync = new object();
+ static Task inFlightEnsureTask;
+ static DateTime lastHealthyUtc;
+
+ ///
+ /// 去重的“确保服务器运行”,同一时间只有一个真实启动逻辑在执行。
+ /// 成功后 10 秒内的重复调用直接返回 true(快速路径)。
+ ///
+ public static Task EnsureServerRunningAsync(UserSettings settings, CancellationToken token)
+ {
+ if (settings == null || settings.Mode != ServerMode.Local)
+ {
+ return Task.FromResult(false);
+ }
+
+ // 若最近一次健康时间在窗口内,直接返回
+ if (lastHealthyUtc != default && (DateTime.UtcNow - lastHealthyUtc) < TimeSpan.FromSeconds(10))
+ {
+ return Task.FromResult(true);
+ }
+
+ // 先做一次快速健康检查,避免不必要锁竞争
+ return EnsureSingleFlightAsync(settings, token);
+ }
+
+ static Task EnsureSingleFlightAsync(UserSettings settings, CancellationToken token)
+ {
+ lock (ensureSync)
+ {
+ if (inFlightEnsureTask != null && !inFlightEnsureTask.IsCompleted)
+ {
+ return inFlightEnsureTask; // 复用正在进行的任务
+ }
+
+ // 创建新的执行任务
+ inFlightEnsureTask = EnsureServerRunningCoreAsync(settings, token);
+ return inFlightEnsureTask;
+ }
+ }
+
+ static async Task EnsureServerRunningCoreAsync(UserSettings settings, CancellationToken token)
+ {
+ // 再检查一次健康(可能在排队期间被其它实例启动)
+ if (await IsHealthyAsync(settings, token).ConfigureAwait(false))
+ {
+ lastHealthyUtc = DateTime.UtcNow;
+ return true;
+ }
+
+ // 情况: 已持有 singleInstanceMutex (说明我们是最初启动者) 但进程被手动终止。
+ // 尝试在不释放互斥的情况下直接重启一次,成功则继续持有;失败再释放让后续调用重新竞争启动。
+ if (singleInstanceMutex != null)
+ {
+ bool restarted = await TryRestartOwnedMutexServerAsync(settings, token).ConfigureAwait(false);
+ if (restarted)
+ {
+ lastHealthyUtc = DateTime.UtcNow;
+ return true;
+ }
+
+ // 重启失败则释放互斥,允许后续重新获取并再试
+ try { singleInstanceMutex.ReleaseMutex(); } catch { }
+ try { singleInstanceMutex.Dispose(); } catch { }
+ singleInstanceMutex = null;
+ }
+
+ bool created;
+ try
+ {
+ singleInstanceMutex = new Mutex(initiallyOwned: true, name: MutexName, createdNew: out created);
+ }
+ catch { created = false; }
+
+ if (created)
+ {
+ // Double check
+ if (!await IsHealthyAsync(settings, token).ConfigureAwait(false))
+ {
+ StartProcess(settings);
+ for (int i = 0; i < 10; i++)
+ {
+ await Task.Delay(500, token).ConfigureAwait(false);
+ if (await IsHealthyAsync(settings, token).ConfigureAwait(false))
+ {
+ lastHealthyUtc = DateTime.UtcNow;
+ return true;
+ }
+ }
+ }
+ var healthy = await IsHealthyAsync(settings, token).ConfigureAwait(false);
+ if (healthy)
+ {
+ lastHealthyUtc = DateTime.UtcNow;
+ return true;
+ }
+ // 启动失败,释放互斥
+ try { singleInstanceMutex?.ReleaseMutex(); } catch { }
+ try { singleInstanceMutex?.Dispose(); } catch { }
+ singleInstanceMutex = null;
+ return false;
+ }
+ // 其他实例已在启动 -> 轮询等待
+ for (int i2 = 0; i2 < 10; i2++)
+ {
+ await Task.Delay(500, token).ConfigureAwait(false);
+ if (await IsHealthyAsync(settings, token).ConfigureAwait(false))
+ {
+ lastHealthyUtc = DateTime.UtcNow;
+ return true;
+ }
+ }
+ return false;
+ }
+
+ static async Task TryRestartOwnedMutexServerAsync(UserSettings settings, CancellationToken token)
+ {
+ try
+ {
+ if (await IsHealthyAsync(settings, token).ConfigureAwait(false)) return true; // 已恢复
+ StartProcess(settings);
+ for (int i = 0; i < 10; i++)
+ {
+ await Task.Delay(500, token).ConfigureAwait(false);
+ if (await IsHealthyAsync(settings, token).ConfigureAwait(false)) return true;
+ }
+ }
+ catch { }
+ return false;
+ }
+
+ public static async Task IsHealthyAsync(UserSettings settings, CancellationToken token)
+ {
+ try
+ {
+ if (string.IsNullOrWhiteSpace(settings.LocalServiceUrl)) return false;
+ var url = settings.LocalServiceUrl.TrimEnd('/') + "/api/Lucene/GetIndexViewList";
+ using (var cts = CancellationTokenSource.CreateLinkedTokenSource(token))
+ {
+ cts.CancelAfter(TimeSpan.FromSeconds(2));
+ var resp = await http.GetAsync(url, cts.Token).ConfigureAwait(false);
+ return resp.IsSuccessStatusCode;
+ }
+ }
+ catch { return false; }
+ }
+
+ public static Task StopServerIfLastAsync(UserSettings settings)
+ {
+ if (settings == null || settings.Mode != ServerMode.Local) return Task.FromResult(false);
+ try
+ {
+ using (var global = new Mutex(false, GlobalClientsMutexName))
+ {
+ global.WaitOne();
+ try
+ {
+ RemoveOwnClientLock_NoThrow(settings);
+ var anyOthers = AnyOtherAliveClient_NoThrow(settings);
+ if (!anyOthers)
+ {
+ TryStopServerProcess(settings);
+ return Task.FromResult(true);
+ }
+ }
+ finally { try { global.ReleaseMutex(); } catch { } }
+ }
+ }
+ catch { }
+ return Task.FromResult(false);
+ }
+
+ public static void ForceStop(UserSettings settings)
+ {
+ TryStopServerProcess(settings);
+ }
+
+ static void StartProcess(UserSettings settings)
+ {
+ try
+ {
+ if (string.IsNullOrWhiteSpace(settings.LocalServerInstallPath)) return;
+ var exe = Directory.EnumerateFiles(settings.LocalServerInstallPath, "CodeIndex.Server*.exe", SearchOption.TopDirectoryOnly)
+ .OrderByDescending(f => f.Length)
+ .FirstOrDefault();
+ if (exe == null || !File.Exists(exe)) return;
+
+ var luceneDir = settings.LocalServerDataDirectory;
+ if (string.IsNullOrWhiteSpace(luceneDir))
+ {
+ luceneDir = Path.Combine(settings.LocalServerInstallPath, "Data");
+ }
+ Directory.CreateDirectory(luceneDir);
+
+ var args = $"--CodeIndex:LuceneIndex=\"{luceneDir}\"";
+ if (!string.IsNullOrWhiteSpace(settings.LocalServiceUrl))
+ {
+ args += $" --urls=\"{settings.LocalServiceUrl}\"";
+ }
+ var psi = new ProcessStartInfo(exe, args)
+ {
+ WorkingDirectory = settings.LocalServerInstallPath,
+ UseShellExecute = false,
+ CreateNoWindow = true
+ };
+ var p = Process.Start(psi);
+ if (p != null)
+ {
+ try
+ {
+ var pidFile = Path.Combine(settings.LocalServerInstallPath, "server.pid");
+ File.WriteAllText(pidFile, p.Id.ToString());
+ }
+ catch { }
+ }
+ }
+ catch { }
+ }
+
+ static void TryStopServerProcess(UserSettings settings)
+ {
+ try
+ {
+ if (string.IsNullOrWhiteSpace(settings.LocalServerInstallPath)) return;
+ // 优先使用 PID 文件
+ var pidFile = Path.Combine(settings.LocalServerInstallPath, "server.pid");
+ bool stopped = false;
+ if (File.Exists(pidFile))
+ {
+ try
+ {
+ var txt = File.ReadAllText(pidFile).Trim();
+ if (int.TryParse(txt, out var pid))
+ {
+ try
+ {
+ var proc = Process.GetProcessById(pid);
+ // 双重确认路径,避免误杀
+ var exePath = proc?.MainModule?.FileName;
+ if (!string.IsNullOrEmpty(exePath) && File.Exists(exePath) && exePath.IndexOf("CodeIndex.Server", StringComparison.OrdinalIgnoreCase) >= 0)
+ {
+ proc.Kill();
+ stopped = true;
+ }
+ }
+ catch { }
+ }
+ }
+ catch { }
+ }
+ if (!stopped)
+ {
+ // 回退:遍历所有进程匹配可执行路径
+ var exeCandidates = Directory.GetFiles(settings.LocalServerInstallPath, "CodeIndex.Server*.exe", SearchOption.TopDirectoryOnly);
+ foreach (var proc in Process.GetProcesses())
+ {
+ try
+ {
+ var module = proc.MainModule; // 可能抛异常 (访问权限)
+ if (module == null) continue;
+ if (exeCandidates.Any(c => string.Equals(c, module.FileName, StringComparison.OrdinalIgnoreCase)))
+ {
+ proc.Kill();
+ }
+ }
+ catch { /* ignore */ }
+ }
+ }
+ }
+ catch { }
+ finally
+ {
+ try { singleInstanceMutex?.ReleaseMutex(); singleInstanceMutex?.Dispose(); singleInstanceMutex = null; } catch { }
+ }
+ }
+
+ #region Client lock helpers
+ static void EnsureClientLockFile(UserSettings settings)
+ {
+ if (settings == null) return;
+ if (clientLockStream != null) return; // 已创建
+ if (string.IsNullOrWhiteSpace(settings.LocalServerInstallPath)) return;
+ try
+ {
+ using (var global = new Mutex(false, GlobalClientsMutexName))
+ {
+ global.WaitOne();
+ try
+ {
+ var folder = Path.Combine(settings.LocalServerInstallPath, ClientLocksFolderName);
+ Directory.CreateDirectory(folder);
+ // 生成唯一文件: -.lock 内容写入时间戳
+ var pid = Process.GetCurrentProcess().Id;
+ clientLockFilePath = Path.Combine(folder, pid + "-" + Guid.NewGuid().ToString("N") + ".lock");
+ clientLockStream = new FileStream(clientLockFilePath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.Read);
+ // .NET Framework 没有只有 (Stream, bool leaveOpen) 的构造,需要显式提供 Encoding 与 bufferSize
+ using (var sw = new StreamWriter(clientLockStream, Encoding.UTF8, 1024, leaveOpen: true))
+ {
+ sw.WriteLine(pid);
+ sw.WriteLine(DateTime.UtcNow.ToString("o"));
+ sw.Flush();
+ }
+ }
+ finally { try { global.ReleaseMutex(); } catch { } }
+ }
+ }
+ catch { /* 忽略 */ }
+ }
+
+ static void RemoveOwnClientLock_NoThrow(UserSettings settings)
+ {
+ try
+ {
+ clientLockStream?.Dispose();
+ clientLockStream = null;
+ if (!string.IsNullOrEmpty(clientLockFilePath) && File.Exists(clientLockFilePath))
+ {
+ File.Delete(clientLockFilePath);
+ }
+ }
+ catch { }
+ }
+
+ static bool AnyOtherAliveClient_NoThrow(UserSettings settings)
+ {
+ try
+ {
+ if (settings == null || string.IsNullOrWhiteSpace(settings.LocalServerInstallPath)) return false;
+ var folder = Path.Combine(settings.LocalServerInstallPath, ClientLocksFolderName);
+ if (!Directory.Exists(folder)) return false; // 无其它
+ var files = Directory.GetFiles(folder, "*.lock", SearchOption.TopDirectoryOnly);
+ bool foundOther = false;
+ foreach (var f in files)
+ {
+ if (string.Equals(f, clientLockFilePath, StringComparison.OrdinalIgnoreCase)) continue; // 自己已删除或即将删除
+ int pid = ParsePidFromFileName(f);
+ if (pid <= 0)
+ {
+ TryDeleteFile(f);
+ continue;
+ }
+ if (!ProcessAlive(pid))
+ {
+ // 僵尸文件
+ TryDeleteFile(f);
+ continue;
+ }
+ // 另一个仍然存活的 VS 进程
+ foundOther = true;
+ }
+ return foundOther;
+ }
+ catch { }
+ return false;
+ }
+
+ static int ParsePidFromFileName(string path)
+ {
+ try
+ {
+ var name = Path.GetFileNameWithoutExtension(path); // pid-guid
+ var dash = name.IndexOf('-');
+ if (dash <= 0) return -1;
+ if (int.TryParse(name.Substring(0, dash), out var pid)) return pid;
+ }
+ catch { }
+ return -1;
+ }
+
+ static bool ProcessAlive(int pid)
+ {
+ try { Process.GetProcessById(pid); return true; } catch { return false; }
+ }
+
+ static void TryDeleteFile(string f)
+ {
+ try { File.Delete(f); } catch { }
+ }
+ #endregion
+ }
+}
diff --git a/src/CodeIndex.VisualStudioExtension/Models/SettingsViewModel.cs b/src/CodeIndex.VisualStudioExtension/Models/SettingsViewModel.cs
new file mode 100644
index 0000000..c7f9820
--- /dev/null
+++ b/src/CodeIndex.VisualStudioExtension/Models/SettingsViewModel.cs
@@ -0,0 +1,696 @@
+using System;
+using System.ComponentModel;
+using System.IO;
+using System.Net.Http;
+using System.Runtime.CompilerServices;
+using System.Threading.Tasks;
+using System.Windows.Input;
+using Microsoft.Win32;
+using System.Diagnostics;
+using System.Linq; // For FirstOrDefault
+using System.IO.Compression; // For Zip extraction
+using System.Collections.Generic; // For Queue in log tail
+
+namespace CodeIndex.VisualStudioExtension.Models
+{
+ public class SettingsViewModel : INotifyPropertyChanged
+ {
+ // 简单扩展帮助方法
+ // 放在类内部避免额外文件;.NET Framework 无 StartProcess 扩展
+ Process StartProcess(ProcessStartInfo psi)
+ {
+ return Process.Start(psi);
+ }
+ readonly UserSettings settings;
+ bool isBusy;
+ double downloadProgress; // 0-100
+ string healthStatus = "Unknown"; // Started / Stopped / Error / Unknown
+ bool isCheckingHealth;
+ bool isOperatingServer; // 启动/停止/重启中的状态,避免按钮重复点击
+
+ public SettingsViewModel(UserSettings settings)
+ {
+ this.settings = settings;
+ RemoteServiceUrl = settings.RemoteServiceUrl;
+ LocalServiceUrl = settings.LocalServiceUrl;
+ LocalServerInstallPath = settings.LocalServerInstallPath;
+ LocalServerDataDirectory = settings.LocalServerDataDirectory;
+ LocalServerVersion = settings.LocalServerVersion;
+ IsLocalMode = settings.Mode == ServerMode.Local;
+ IsRemoteMode = settings.Mode == ServerMode.Remote;
+
+ // 打开设置界面立即触发一次健康检查(异步,不阻塞 UI)
+ if (IsLocalMode && !string.IsNullOrWhiteSpace(LocalServiceUrl))
+ {
+ _ = CheckHealthAsync();
+ }
+ }
+
+ public event PropertyChangedEventHandler PropertyChanged;
+ void Raise([CallerMemberName] string p = null) => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(p));
+
+ public bool IsRemoteMode
+ {
+ get => !IsLocalMode;
+ set
+ {
+ if (value)
+ {
+ IsLocalMode = false;
+ Raise();
+ Raise(nameof(IsLocalMode));
+ }
+ }
+ }
+
+ bool isLocalMode;
+ public bool IsLocalMode
+ {
+ get => isLocalMode;
+ set
+ {
+ if (isLocalMode != value)
+ {
+ isLocalMode = value;
+ Raise();
+ Raise(nameof(IsRemoteMode));
+ }
+ }
+ }
+
+ public string RemoteServiceUrl { get; set; }
+ public string LocalServiceUrl { get; set; }
+ public string LocalServerInstallPath { get; set; }
+ public string LocalServerDataDirectory { get; set; }
+ public string LocalServerVersion { get; set; }
+
+ public double DownloadProgress
+ {
+ get => downloadProgress;
+ set { downloadProgress = value; Raise(); }
+ }
+
+ public string HealthStatus
+ {
+ get => healthStatus;
+ set { healthStatus = value; Raise(); Raise(nameof(IsServerRunning)); RefreshButtonsState(); }
+ }
+
+ public bool IsServerRunning => string.Equals(HealthStatus, "Started", StringComparison.OrdinalIgnoreCase);
+
+ void RefreshButtonsState()
+ {
+ System.Windows.Input.CommandManager.InvalidateRequerySuggested();
+ Raise(nameof(CanStart));
+ Raise(nameof(CanStop));
+ Raise(nameof(CanRestart));
+ startServerCommand?.RaiseCanExecuteChanged();
+ stopServerCommand?.RaiseCanExecuteChanged();
+ restartServerCommand?.RaiseCanExecuteChanged();
+ }
+
+ public bool CanStart => !isBusy && !isOperatingServer && IsLocalMode && !IsServerRunning;
+ public bool CanStop => !isBusy && !isOperatingServer && IsLocalMode && IsServerRunning;
+ public bool CanRestart => !isBusy && !isOperatingServer && IsLocalMode && IsServerRunning;
+
+ public ICommand BrowseInstallPathCommand => new CommonCommand(_ =>
+ {
+ var picked = FolderPickerHelper.PickFolder(LocalServerInstallPath, "Select Local Server Install Directory (CodeIndex.Server)");
+ if (!string.IsNullOrWhiteSpace(picked))
+ {
+ LocalServerInstallPath = picked;
+ Raise(nameof(LocalServerInstallPath));
+ }
+ }, _ => true);
+
+ public ICommand BrowseDataDirCommand => new CommonCommand(_ =>
+ {
+ var picked = FolderPickerHelper.PickFolder(LocalServerDataDirectory, "Select Local Server Data Directory (Index Storage)");
+ if (!string.IsNullOrWhiteSpace(picked))
+ {
+ LocalServerDataDirectory = picked;
+ Raise(nameof(LocalServerDataDirectory));
+ }
+ }, _ => true);
+
+ public ICommand DownloadOrUpdateCommand => downloadCommand ?? (downloadCommand = new AsyncCommand(DownloadOrUpdateAsync, () => !isBusy, null));
+ public ICommand SaveCommand => new CommonCommand(Save, _ => !isBusy);
+ public ICommand StartServerCommand => startServerCommand ?? (startServerCommand = new AsyncCommand(StartServerAsync, () => CanStart, null));
+ public ICommand StopServerCommand => stopServerCommand ?? (stopServerCommand = new AsyncCommand(StopServerAsync, () => CanStop, null));
+ public ICommand RestartServerCommand => restartServerCommand ?? (restartServerCommand = new AsyncCommand(RestartServerAsync, () => CanRestart, null));
+ public ICommand RefreshLogCommand => new AsyncCommand(RefreshLogAsync, () => true, null);
+ public ICommand CheckHealthCommand => new AsyncCommand(async () => await CheckHealthAsync(), () => !isCheckingHealth, null);
+ public ICommand OpenLocalServiceUrlCommand => openLocalServiceUrlCommand ?? (openLocalServiceUrlCommand = new CommonCommand(OpenLocalServiceUrl, _ => !string.IsNullOrWhiteSpace(LocalServiceUrl)));
+ public ICommand OpenRemoteServiceUrlCommand => openRemoteServiceUrlCommand ?? (openRemoteServiceUrlCommand = new CommonCommand(OpenRemoteServiceUrl, _ => !string.IsNullOrWhiteSpace(RemoteServiceUrl)));
+
+ AsyncCommand downloadCommand;
+ AsyncCommand startServerCommand;
+ AsyncCommand stopServerCommand;
+ AsyncCommand restartServerCommand;
+ CommonCommand openLocalServiceUrlCommand;
+ CommonCommand openRemoteServiceUrlCommand;
+
+ public string LogContent
+ {
+ get => logContent;
+ set { logContent = value; Raise(); }
+ }
+ string logContent;
+
+ // 不再在 ViewModel 中直接保存进程;统一由 LocalServerLauncher 负责生命周期
+
+ const string ReleaseListUrl = "https://github.com/qiuhaotc/CodeIndex/releases"; // 用于解析最新 tag
+ const string FixedZipUrlTemplate = "https://github.com/qiuhaotc/CodeIndex/releases/download/{0}/CodeIndex.Server.zip"; // {tag}
+ static readonly HttpClient sharedHttp = new HttpClient();
+
+ async Task DownloadOrUpdateAsync()
+ {
+ try
+ {
+ isBusy = true; Raise(nameof(IsLocalMode)); // 触发命令刷新
+
+ if (string.IsNullOrWhiteSpace(LocalServerInstallPath))
+ {
+ LocalServerInstallPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "CodeIndex.VisualStudioExtension", "CodeIndex.Server");
+ Raise(nameof(LocalServerInstallPath));
+
+ if (string.IsNullOrWhiteSpace(LocalServerDataDirectory))
+ {
+ LocalServerDataDirectory = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "CodeIndex.VisualStudioExtension", "CodeIndex.Server.Data");
+ Raise(nameof(LocalServerDataDirectory));
+ Directory.CreateDirectory(LocalServerDataDirectory);
+ }
+ }
+
+ Directory.CreateDirectory(LocalServerInstallPath);
+
+ // 解析 releases 页面简单提取第一个 href="/qiuhaotc/CodeIndex/releases/tag/v..."
+ string html = await sharedHttp.GetStringAsync(ReleaseListUrl);
+ var tag = ParseFirstTag(html) ?? "v0.98_t"; // 回退已知版本
+ if (tag.StartsWith("/qiuhaotc/CodeIndex/releases/tag/"))
+ {
+ tag = tag.Substring("/qiuhaotc/CodeIndex/releases/tag/".Length);
+ }
+
+ // 修正: 之前只比较版本号, 若本地版本号已保存但实际文件缺失会误判为无需下载。
+ // 判定需要下载的条件:
+ // 1. 未记录版本(LocalServerVersion 为空)
+ // 2. 版本不一致
+ // 3. 关键文件( CodeIndex.Server.dll ) 不存在 (可能被用户手动删除或首次尚未真正下载)
+ var serverDllPath = string.IsNullOrWhiteSpace(LocalServerInstallPath)
+ ? null
+ : Path.Combine(LocalServerInstallPath, "CodeIndex.Server.dll");
+ bool serverInstalled = !string.IsNullOrWhiteSpace(serverDllPath) && File.Exists(serverDllPath);
+ bool needDownload = string.IsNullOrWhiteSpace(LocalServerVersion)
+ || !string.Equals(LocalServerVersion, tag, StringComparison.OrdinalIgnoreCase)
+ || !serverInstalled;
+ if (!needDownload)
+ {
+ System.Windows.MessageBox.Show($"Already latest: {tag}", "CodeIndex", System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Information);
+ return;
+ }
+
+ var zipUrl = string.Format(FixedZipUrlTemplate, tag);
+ var tempZip = Path.Combine(Path.GetTempPath(), $"CodeIndex.Server_{tag}.zip");
+ try
+ {
+ DownloadProgress = 0;
+ using (var resp = await sharedHttp.GetAsync(zipUrl, HttpCompletionOption.ResponseHeadersRead))
+ {
+ resp.EnsureSuccessStatusCode();
+ var total = resp.Content.Headers.ContentLength;
+ using (var rs = await resp.Content.ReadAsStreamAsync())
+ using (var fs = new FileStream(tempZip, FileMode.Create, FileAccess.Write, FileShare.None))
+ {
+ var buffer = new byte[81920];
+ long readTotal = 0;
+ int read;
+ while ((read = await rs.ReadAsync(buffer, 0, buffer.Length)) > 0)
+ {
+ await fs.WriteAsync(buffer, 0, read);
+ readTotal += read;
+ if (total.HasValue && total.Value > 0)
+ {
+ DownloadProgress = Math.Round(readTotal * 100.0 / total.Value, 1);
+ }
+ }
+ DownloadProgress = 100;
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ System.Windows.MessageBox.Show("Download failed: " + ex.Message, "CodeIndex", System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Error);
+ return;
+ }
+
+ // 解压:简单处理,若存在旧文件尝试覆盖
+ try
+ {
+ // 解压前先尝试停止当前运行的服务器
+ await StopServerCoreAsync();
+ // 自定义覆盖解压,兼容 .NET Framework (无 ExtractToDirectory(..., overwriteFiles))
+ using (var archive = ZipFile.OpenRead(tempZip))
+ {
+ foreach (var entry in archive.Entries)
+ {
+ var destinationPath = Path.Combine(LocalServerInstallPath, entry.FullName);
+ if (string.IsNullOrEmpty(entry.Name))
+ {
+ Directory.CreateDirectory(destinationPath);
+ continue;
+ }
+ Directory.CreateDirectory(Path.GetDirectoryName(destinationPath));
+ if (File.Exists(destinationPath))
+ {
+ try { File.Delete(destinationPath); } catch { }
+ }
+ entry.ExtractToFile(destinationPath);
+ }
+ }
+ // 解压完成后尝试删除临时压缩包
+ try { if (File.Exists(tempZip)) File.Delete(tempZip); } catch { }
+ }
+ catch (Exception ex)
+ {
+ System.Windows.MessageBox.Show("Extract failed: " + ex.Message, "CodeIndex", System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Error);
+ return;
+ }
+
+ LocalServerVersion = tag;
+ Raise(nameof(LocalServerVersion));
+ System.Windows.MessageBox.Show($"Updated to {tag}", "CodeIndex", System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Information);
+ }
+ finally
+ {
+ isBusy = false;
+ }
+ }
+
+ static string ParseFirstTag(string html)
+ {
+ if (string.IsNullOrEmpty(html)) return null;
+ // 粗略查找 pattern:/qiuhaotc/CodeIndex/releases/tag/v...
+ var marker = "/qiuhaotc/CodeIndex/releases/tag/";
+ var idx = html.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
+ if (idx < 0) return null;
+ var start = idx;
+ var end = html.IndexOf('"', start + marker.Length);
+ if (end < 0) return null;
+ return html.Substring(start, end - start);
+ }
+
+ async Task StartServerCoreAsync() => await CodeIndex.VisualStudioExtension.Models.LocalServerLauncher.EnsureServerRunningAsync(settings, System.Threading.CancellationToken.None);
+ async Task StopServerCoreAsync() { await CodeIndex.VisualStudioExtension.Models.LocalServerLauncher.StopServerIfLastAsync(settings); }
+
+ async Task StartServerAsync()
+ {
+ if (isOperatingServer) return;
+ try
+ {
+ isOperatingServer = true; RefreshButtonsState();
+ HealthStatus = "Starting"; // 立即反馈
+ await StartServerCoreAsync();
+ await PollHealthAsync(maxAttempts: 12, delayMs: 500, successStatus: "Started", failStatus: "Error");
+ }
+ finally
+ {
+ isOperatingServer = false; RefreshButtonsState();
+ }
+ }
+
+ async Task StopServerAsync()
+ {
+ if (isOperatingServer) return;
+ try
+ {
+ isOperatingServer = true; RefreshButtonsState();
+ HealthStatus = "Stopping";
+ await StopServerCoreAsync();
+ // 简单等待一小段然后检查
+ await Task.Delay(300);
+ await PollHealthAsync(maxAttempts: 5, delayMs: 400, successStatus: "Stopped", failStatus: "Stopped", expectStopped: true);
+ }
+ finally
+ {
+ isOperatingServer = false; RefreshButtonsState();
+ }
+ }
+
+ async Task RestartServerAsync()
+ {
+ if (isOperatingServer) return;
+ try
+ {
+ isOperatingServer = true; RefreshButtonsState();
+ HealthStatus = "Restarting";
+ await StopServerCoreAsync();
+ await Task.Delay(400); // 给进程退出一点时间
+ await StartServerCoreAsync();
+ await PollHealthAsync(maxAttempts: 14, delayMs: 500, successStatus: "Started", failStatus: "Error");
+ }
+ finally
+ {
+ isOperatingServer = false; RefreshButtonsState();
+ }
+ }
+
+ async Task PollHealthAsync(int maxAttempts, int delayMs, string successStatus, string failStatus, bool expectStopped = false)
+ {
+ for (int i = 0; i < maxAttempts; i++)
+ {
+ await Task.Delay(delayMs);
+ try
+ {
+ var url = LocalServiceUrl?.TrimEnd('/') + "/api/Lucene/GetIndexViewList";
+ if (string.IsNullOrWhiteSpace(LocalServiceUrl)) break;
+ using (var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(2)))
+ {
+ var resp = await sharedHttp.GetAsync(url, cts.Token);
+ if (expectStopped)
+ {
+ if (!resp.IsSuccessStatusCode)
+ {
+ HealthStatus = successStatus; return;
+ }
+ }
+ else if (resp.IsSuccessStatusCode)
+ {
+ HealthStatus = successStatus; return;
+ }
+ }
+ }
+ catch
+ {
+ if (expectStopped)
+ {
+ HealthStatus = successStatus; return;
+ }
+ }
+ }
+ // 超时未达到期望
+ if (expectStopped)
+ {
+ HealthStatus = successStatus; // 停止判定上宽松
+ }
+ else
+ {
+ // 若已经是 Started 则不覆盖
+ if (!IsServerRunning)
+ HealthStatus = failStatus;
+ }
+ }
+
+ async Task RefreshLogAsync()
+ {
+ try
+ {
+ if (string.IsNullOrWhiteSpace(LocalServerInstallPath)) return;
+ var logFile = Path.Combine(LocalServerInstallPath, "Logs", "CodeIndex.log");
+ if (!File.Exists(logFile))
+ {
+ LogContent = "(log file not found)";
+ return;
+ }
+ // 读取最新 100 行
+ using (var fs = new FileStream(logFile, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
+ using (var sr = new StreamReader(fs))
+ {
+ var q = new Queue(100);
+ string line;
+ while ((line = await sr.ReadLineAsync()) != null)
+ {
+ if (q.Count == 100) q.Dequeue();
+ q.Enqueue(line);
+ }
+ LogContent = string.Join(Environment.NewLine, q.ToArray());
+ }
+ }
+ catch (Exception ex)
+ {
+ LogContent = "Read log failed: " + ex.Message;
+ }
+ }
+
+ void Save(object _)
+ {
+ settings.RemoteServiceUrl = RemoteServiceUrl?.TrimEnd('/');
+ settings.LocalServiceUrl = LocalServiceUrl?.TrimEnd('/');
+ settings.LocalServerInstallPath = LocalServerInstallPath;
+ settings.LocalServerDataDirectory = LocalServerDataDirectory;
+ settings.LocalServerVersion = LocalServerVersion;
+ settings.Mode = IsLocalMode ? ServerMode.Local : ServerMode.Remote;
+ UserSettingsManager.Save(settings);
+ CloseWindow(true);
+ }
+
+ void OpenLocalServiceUrl(object _)
+ {
+ try
+ {
+ var url = LocalServiceUrl;
+ if (string.IsNullOrWhiteSpace(url)) return;
+ url = url.Trim();
+ if (!url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && !url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
+ {
+ url = "http://" + url.TrimStart('/');
+ }
+ StartProcess(new ProcessStartInfo
+ {
+ FileName = url,
+ UseShellExecute = true
+ });
+ }
+ catch (Exception ex)
+ {
+ System.Windows.MessageBox.Show("Open failed: " + ex.Message, "CodeIndex", System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Error);
+ }
+ }
+
+ void OpenRemoteServiceUrl(object _)
+ {
+ try
+ {
+ var url = RemoteServiceUrl;
+ if (string.IsNullOrWhiteSpace(url)) return;
+ url = url.Trim();
+ if (!url.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && !url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
+ {
+ url = "http://" + url.TrimStart('/');
+ }
+ StartProcess(new ProcessStartInfo
+ {
+ FileName = url,
+ UseShellExecute = true
+ });
+ }
+ catch (Exception ex)
+ {
+ System.Windows.MessageBox.Show("Open failed: " + ex.Message, "CodeIndex", System.Windows.MessageBoxButton.OK, System.Windows.MessageBoxImage.Error);
+ }
+ }
+
+ public async Task CheckHealthAsync()
+ {
+ if (isCheckingHealth) return;
+ if (!IsLocalMode)
+ {
+ HealthStatus = "Unknown";
+ return;
+ }
+ if (string.IsNullOrWhiteSpace(LocalServiceUrl))
+ {
+ HealthStatus = "Unknown";
+ return;
+ }
+ try
+ {
+ isCheckingHealth = true;
+ var url = LocalServiceUrl.TrimEnd('/') + "/api/Lucene/GetIndexViewList";
+ using (var cts = new System.Threading.CancellationTokenSource(TimeSpan.FromSeconds(3)))
+ {
+ var resp = await sharedHttp.GetAsync(url, cts.Token);
+ if (resp.IsSuccessStatusCode)
+ {
+ HealthStatus = "Started";
+ }
+ else
+ {
+ HealthStatus = "Error";
+ }
+ }
+ }
+ catch
+ {
+ HealthStatus = "Stopped";
+ }
+ finally
+ {
+ isCheckingHealth = false;
+ }
+ }
+
+ void CloseWindow(bool dialogResult)
+ {
+ // 通过打开时的 Window 关联关闭
+ foreach (System.Windows.Window w in System.Windows.Application.Current.Windows)
+ {
+ if (w.DataContext == this)
+ {
+ w.DialogResult = dialogResult;
+ w.Close();
+ break;
+ }
+ }
+ }
+ }
+
+ // 基于 Vista 及以上的 IFileOpenDialog 实现的现代文件夹选择器,体验接近 VS / VSCode 打开文件夹窗口
+ internal static class FolderPickerHelper
+ {
+ public static string PickFolder(string initialPath, string title = null)
+ {
+ try
+ {
+ var dialog = (IFileOpenDialog)new FileOpenDialogRCW();
+ uint options;
+ dialog.GetOptions(out options);
+ // 允许选择文件夹 / 仅文件系统 / 路径必须存在 / 允许创建新文件夹按钮
+ options |= (uint)(FOS.FOS_PICKFOLDERS | FOS.FOS_FORCEFILESYSTEM | FOS.FOS_PATHMUSTEXIST | FOS.FOS_CREATEPROMPT | FOS.FOS_NOREADONLYRETURN);
+ dialog.SetOptions(options);
+
+ // 初始目录
+ if (!string.IsNullOrWhiteSpace(initialPath) && System.IO.Directory.Exists(initialPath))
+ {
+ IShellItem folderItem;
+ if (SHCreateItemFromParsingName(initialPath, IntPtr.Zero, typeof(IShellItem).GUID, out folderItem) == 0)
+ {
+ dialog.SetFolder(folderItem);
+ }
+ }
+
+ // 预填一个名称(可输入路径)
+ if (!string.IsNullOrWhiteSpace(initialPath))
+ {
+ try { dialog.SetFileName(initialPath); } catch { }
+ }
+
+ if (!string.IsNullOrWhiteSpace(title))
+ {
+ try { dialog.SetTitle(title); } catch { }
+ }
+
+ var hr = dialog.Show(GetActiveWindow());
+ if (hr == (int)HRESULT.ERROR_CANCELLED) return null; // 用户取消
+ if (hr != 0) return null; // 其他错误
+
+ IShellItem result;
+ dialog.GetResult(out result);
+ if (result == null) return null;
+ string path;
+ result.GetDisplayName(SIGDN.SIGDN_FILESYSPATH, out path);
+ return path;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ // --- COM / PInvoke 定义 ---
+ [System.Runtime.InteropServices.ComImport]
+ [System.Runtime.InteropServices.Guid("DC1C5A9C-E88A-4DDE-A5A1-60F82A20AEF7")]
+ private class FileOpenDialogRCW { }
+
+ [System.Runtime.InteropServices.ComImport]
+ [System.Runtime.InteropServices.InterfaceType(System.Runtime.InteropServices.ComInterfaceType.InterfaceIsIUnknown)]
+ [System.Runtime.InteropServices.Guid("42f85136-db7e-439c-85f1-e4075d135fc8")]
+ private interface IFileOpenDialog
+ {
+ // IModalWindow
+ [System.Runtime.InteropServices.PreserveSig]
+ int Show(IntPtr parent);
+ // IFileDialog (部分,只保留需要的方法顺序需与原接口一致)
+ void SetFileTypes(); // 未使用
+ void SetFileTypeIndex(uint iFileType);
+ void GetFileTypeIndex(out uint piFileType);
+ void Advise();
+ void Unadvise();
+ void SetOptions(uint fos);
+ void GetOptions(out uint pfos);
+ void SetDefaultFolder(IShellItem psi);
+ void SetFolder(IShellItem psi);
+ void GetFolder(out IShellItem ppsi);
+ void GetCurrentSelection(out IShellItem ppsi);
+ void SetFileName([System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.LPWStr)] string pszName);
+ void GetFileName([System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.LPWStr)] out string pszName);
+ void SetTitle([System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.LPWStr)] string pszTitle);
+ void SetOkButtonLabel([System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.LPWStr)] string pszText);
+ void SetFileNameLabel([System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.LPWStr)] string pszLabel);
+ void GetResult(out IShellItem ppsi);
+ void AddPlace(IShellItem psi, int fdap);
+ void SetDefaultExtension([System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.LPWStr)] string pszDefaultExtension);
+ void Close(int hr);
+ void SetClientGuid();
+ void ClearClientData();
+ void SetFilter();
+ // IFileOpenDialog
+ void GetResults();
+ void GetSelectedItems();
+ }
+
+ [System.Runtime.InteropServices.ComImport]
+ [System.Runtime.InteropServices.InterfaceType(System.Runtime.InteropServices.ComInterfaceType.InterfaceIsIUnknown)]
+ [System.Runtime.InteropServices.Guid("43826D1E-E718-42EE-BC55-A1E261C37BFE")]
+ private interface IShellItem
+ {
+ void BindToHandler();
+ void GetParent();
+ void GetDisplayName(SIGDN sigdnName, [System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.LPWStr)] out string ppszName);
+ void GetAttributes();
+ void Compare();
+ }
+
+ [System.Runtime.InteropServices.DllImport("shell32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode, SetLastError = true)]
+ private static extern int SHCreateItemFromParsingName([System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.LPWStr)] string pszPath, IntPtr pbc, [System.Runtime.InteropServices.In] System.Guid riid, out IShellItem ppv);
+
+ [System.Runtime.InteropServices.DllImport("user32.dll")]
+ private static extern IntPtr GetActiveWindow();
+
+ private enum HRESULT : int
+ {
+ ERROR_CANCELLED = unchecked((int)0x800704C7)
+ }
+
+ [Flags]
+ private enum FOS : uint
+ {
+ FOS_OVERWRITEPROMPT = 0x00000002,
+ FOS_STRICTFILETYPES = 0x00000004,
+ FOS_NOCHANGEDIR = 0x00000008,
+ FOS_PICKFOLDERS = 0x00000020,
+ FOS_FORCEFILESYSTEM = 0x00000040,
+ FOS_ALLNONSTORAGEITEMS = 0x00000080,
+ FOS_NOVALIDATE = 0x00000100,
+ FOS_ALLOWMULTISELECT = 0x00000200,
+ FOS_PATHMUSTEXIST = 0x00000800,
+ FOS_FILEMUSTEXIST = 0x00001000,
+ FOS_CREATEPROMPT = 0x00002000,
+ FOS_SHAREAWARE = 0x00004000,
+ FOS_NOREADONLYRETURN = 0x00008000,
+ FOS_NOTESTFILECREATE = 0x00010000,
+ FOS_HIDEMRUPLACES = 0x00020000,
+ FOS_HIDEPINNEDPLACES = 0x00040000,
+ FOS_NODEREFERENCELINKS = 0x00100000,
+ FOS_OKBUTTONNEEDSINTERACTION = 0x00200000,
+ FOS_DONTADDTORECENT = 0x02000000,
+ FOS_FORCESHOWHIDDEN = 0x10000000,
+ FOS_DEFAULTNOMINIMODE = 0x20000000
+ }
+
+ private enum SIGDN : uint
+ {
+ SIGDN_FILESYSPATH = 0x80058000
+ }
+ }
+}
diff --git a/src/CodeIndex.VisualStudioExtension/Models/UserSettingsManager.cs b/src/CodeIndex.VisualStudioExtension/Models/UserSettingsManager.cs
new file mode 100644
index 0000000..36762c5
--- /dev/null
+++ b/src/CodeIndex.VisualStudioExtension/Models/UserSettingsManager.cs
@@ -0,0 +1,118 @@
+using System;
+using System.IO;
+using Newtonsoft.Json;
+
+namespace CodeIndex.VisualStudioExtension
+{
+ public enum ServerMode
+ {
+ Remote = 0,
+ Local = 1
+ }
+
+ public class UserSettings
+ {
+ // 旧版本字段:仍然保留用于向后兼容(Remote 模式 URL)
+ public string ServiceUrl { get; set; } = "http://localhost:5000";
+
+ // 新增:区分当前服务器模式
+ public ServerMode Mode { get; set; } = ServerMode.Remote;
+
+ // 远程服务器 URL(如果与旧 ServiceUrl 不同,可独立设置)
+ public string RemoteServiceUrl { get; set; } = "http://localhost:5000";
+
+ // 本地服务器监听 URL(下载/启动后可固定,如 http://localhost:58080)
+ public string LocalServiceUrl { get; set; } = "http://localhost:58080";
+
+ // 本地服务器安装根目录(下载、解压存放位置)
+ public string LocalServerInstallPath { get; set; } = string.Empty;
+
+ // 本地服务器数据目录(索引数据)
+ public string LocalServerDataDirectory { get; set; } = string.Empty;
+
+ // 记录最近一次成功安装的服务器版本(用于判断是否需要重新下载)
+ public string LocalServerVersion { get; set; } = string.Empty;
+ }
+
+ internal static class UserSettingsManager
+ {
+ static readonly object Locker = new();
+ static UserSettings cached;
+ internal static string SettingsDirectory => Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "CodeIndex.VisualStudioExtension");
+ internal static string SettingsFile => Path.Combine(SettingsDirectory, "codeindex.user.settings.json");
+
+ public static UserSettings Load()
+ {
+ lock (Locker)
+ {
+ if (cached != null)
+ {
+ return cached;
+ }
+
+ try
+ {
+ if (File.Exists(SettingsFile))
+ {
+ cached = JsonConvert.DeserializeObject(File.ReadAllText(SettingsFile)) ?? new UserSettings();
+ PostLoadBackFill(cached);
+ }
+ else
+ {
+ cached = new UserSettings();
+ // 兼容旧版本:尝试迁移原有配置文件中的 ServiceUrl
+ try
+ {
+ var legacy = ConfigHelper.Configuration?.AppSettings?.Settings?[nameof(UserSettings.ServiceUrl)]?.Value;
+ if (!string.IsNullOrWhiteSpace(legacy))
+ {
+ cached.ServiceUrl = legacy;
+ cached.RemoteServiceUrl = legacy; // 同步到新字段
+ Save(cached); // 立即保存迁移
+ }
+ }
+ catch { /* 忽略迁移异常 */ }
+ PostLoadBackFill(cached);
+ }
+ }
+ catch
+ {
+ cached = new UserSettings();
+ PostLoadBackFill(cached);
+ }
+
+ return cached;
+ }
+ }
+
+ public static void Save(UserSettings settings)
+ {
+ lock (Locker)
+ {
+ try
+ {
+ Directory.CreateDirectory(SettingsDirectory);
+ File.WriteAllText(SettingsFile, JsonConvert.SerializeObject(settings, Formatting.Indented));
+ cached = settings;
+ }
+ catch
+ {
+ // 记录日志可选:当前扩展无集中日志设施
+ }
+ }
+ }
+
+ static void PostLoadBackFill(UserSettings s)
+ {
+ // Back-fill 逻辑:旧版本只有 ServiceUrl
+ if (string.IsNullOrWhiteSpace(s.RemoteServiceUrl))
+ {
+ s.RemoteServiceUrl = s.ServiceUrl;
+ }
+ if (string.IsNullOrWhiteSpace(s.LocalServiceUrl))
+ {
+ s.LocalServiceUrl = "http://localhost:58080";
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/CodeIndex.VisualStudioExtension/Resources/ExtensionResourceDictionary.xaml b/src/CodeIndex.VisualStudioExtension/Resources/ExtensionResourceDictionary.xaml
index 0613293..e8f4b1e 100644
--- a/src/CodeIndex.VisualStudioExtension/Resources/ExtensionResourceDictionary.xaml
+++ b/src/CodeIndex.VisualStudioExtension/Resources/ExtensionResourceDictionary.xaml
@@ -1,25 +1,51 @@
-
+ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
+ xmlns:local="clr-namespace:CodeIndex.VisualStudioExtension"
+ xmlns:vsshell="clr-namespace:Microsoft.VisualStudio.Shell;assembly=Microsoft.VisualStudio.Shell.15.0">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/CodeIndex.VisualStudioExtension/source.extension.vsixmanifest b/src/CodeIndex.VisualStudioExtension/source.extension.vsixmanifest
index 71a6f87..23007aa 100644
--- a/src/CodeIndex.VisualStudioExtension/source.extension.vsixmanifest
+++ b/src/CodeIndex.VisualStudioExtension/source.extension.vsixmanifest
@@ -1,7 +1,7 @@
-
+
Code Index Search
Code index search extension for visual studio
CodeIndex: a fast code searching tools based on Lucene.Net
@@ -12,16 +12,24 @@ More details see: https://github.com/qiuhaotc/CodeIndex
Code full-text search
-
-
-
-
@@ -29,7 +37,7 @@ More details see: https://github.com/qiuhaotc/CodeIndex
-
+
diff --git a/src/CodeIndex.sln b/src/CodeIndex.sln
index 14368f1..35069b8 100644
--- a/src/CodeIndex.sln
+++ b/src/CodeIndex.sln
@@ -1,7 +1,7 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
-VisualStudioVersion = 17.13.35828.75 d17.13
+VisualStudioVersion = 17.13.35828.75
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CodeIndex.Test", "CodeIndex.Test\CodeIndex.Test.csproj", "{5532620B-E050-4322-9648-C426133BAED8}"
EndProject
@@ -31,6 +31,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
ProjectSection(SolutionItems) = preProject
.editorconfig = .editorconfig
..\.github\workflows\dotnetcore.yml = ..\.github\workflows\dotnetcore.yml
+ ..\.github\workflows\publish.yml = ..\.github\workflows\publish.yml
EndProjectSection
EndProject
Global