diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml
new file mode 100644
index 0000000..2328612
--- /dev/null
+++ b/.github/workflows/ci-build.yml
@@ -0,0 +1,42 @@
+name: CI Build (VSIX)
+
+# Compile l'extension VSIX sur un runner Windows cloud à chaque push.
+# Sert de "compilateur distant" : permet de vérifier que le code compile
+# sans avoir Visual Studio / Windows en local.
+on:
+ push:
+ branches: [main]
+ pull_request:
+ branches: [main]
+ workflow_dispatch:
+
+jobs:
+ build:
+ name: Build sur Windows
+ runs-on: windows-latest
+ defaults:
+ run:
+ working-directory: visualstudio-extension
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup MSBuild
+ uses: microsoft/setup-msbuild@v2
+
+ - name: Setup NuGet
+ uses: NuGet/setup-nuget@v2
+
+ - name: Restore NuGet packages
+ run: nuget restore DevGlobe.sln
+
+ - name: Build (Release, sans déploiement local)
+ run: msbuild DevGlobe.sln /p:Configuration=Release /p:DeployExtension=false /m /v:minimal
+
+ - name: Upload du VSIX produit
+ if: success()
+ uses: actions/upload-artifact@v4
+ with:
+ name: DevGlobe-vsix
+ path: '**/bin/Release/*.vsix'
+ if-no-files-found: warn
diff --git a/.github/workflows/publish-visualstudio.yml b/.github/workflows/publish-visualstudio.yml
new file mode 100644
index 0000000..06cb455
--- /dev/null
+++ b/.github/workflows/publish-visualstudio.yml
@@ -0,0 +1,60 @@
+name: Publish Visual Studio Extension (GitHub only)
+
+on:
+ push:
+ tags:
+ - "vs-v*"
+ workflow_dispatch:
+
+jobs:
+ publish:
+ runs-on: windows-latest
+ permissions:
+ contents: write
+ defaults:
+ run:
+ working-directory: visualstudio-extension
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup MSBuild
+ uses: microsoft/setup-msbuild@v2
+
+ - name: Setup NuGet
+ uses: nuget/setup-nuget@v2
+
+ - name: Restore NuGet packages
+ run: nuget restore DevGlobe.sln
+
+ - name: Build VSIX (Release)
+ run: msbuild DevGlobe.sln /t:Rebuild /p:Configuration=Release /p:DeployExtension=false
+
+ - name: Locate VSIX
+ id: vsix
+ shell: pwsh
+ run: |
+ $vsix = Get-ChildItem -Path . -Recurse -Filter *.vsix |
+ Where-Object { $_.FullName -match 'bin\\Release' } |
+ Select-Object -First 1
+ if (-not $vsix) {
+ Write-Error "No .vsix found under bin\Release"
+ exit 1
+ }
+ Write-Host "Found VSIX: $($vsix.FullName)"
+ "path=$($vsix.FullName)" >> $env:GITHUB_OUTPUT
+
+ - name: Create GitHub Release
+ uses: softprops/action-gh-release@v2
+ with:
+ files: ${{ steps.vsix.outputs.path }}
+ body: |
+ ## DevGlobe for Visual Studio
+
+ Built from commit ${{ github.sha }}
+
+ ### Install manually
+ 1. Download the `.vsix` file below
+ 2. Double-click it (or use **Extensions → Manage Extensions → Install from VSIX…**)
+ 3. Restart Visual Studio
+ generate_release_notes: true
diff --git a/PRIVACY.md b/PRIVACY.md
index f7c0193..25d16d0 100644
--- a/PRIVACY.md
+++ b/PRIVACY.md
@@ -79,6 +79,7 @@ If your profile is set to `anonymous`, the server stores no precise coordinates
|-----|---------|
| VS Code | OS keychain via `SecretStorage` (macOS Keychain, Windows Credential Manager, Linux libsecret). Old plaintext entries in `settings.json` are migrated automatically. |
| JetBrains | OS keychain via `PasswordSafe`. |
+| Visual Studio | Windows Credential Manager. The `devglobe-core` daemon reads the key from `~/.devglobe/config.toml` (written with restrictive permissions). |
| Zed, NeoVim, Claude Code, Codex, OpenCode | `~/.devglobe/config.toml`, written with `0600` permissions. |
---
diff --git a/README.md b/README.md
index 62626ba..9b7f611 100644
--- a/README.md
+++ b/README.md
@@ -16,6 +16,7 @@
VS Code ·
+ Visual Studio ·
JetBrains ·
Zed ·
NeoVim ·
@@ -93,6 +94,43 @@ Available from the Command Palette (`Ctrl+Shift+P` / `Cmd+Shift+P`):
---
+### Visual Studio
+
+The full **Visual Studio** IDE on Windows (not to be confused with VS Code).
+
+#### Installation
+
+1. Install from the [Visual Studio Marketplace](https://marketplace.visualstudio.com/) (search **DevGlobe**) or download the `.vsix` from the [Releases](https://github.com/Nako0/devglobe-extension/releases) and install it via **Extensions → Manage Extensions → Install from VSIX…**
+2. Open the **DevGlobe** panel — click the **globe button** in the toolbar, or **View → Other Windows → DevGlobe**
+3. Paste your API key → **Connect**
+
+On first launch, the extension downloads the matching `devglobe-core` binary for Windows from [GitHub Releases](https://github.com/Nako0/devglobe-extension/releases) (one-time) and caches it under `%LOCALAPPDATA%\DevGlobe\core`.
+
+#### Tool window
+
+- **Login** — masked API key field + link to get your key on devglobe.app
+- **Dashboard** — live coding time, active language, status message, start/stop tracking, disconnect
+
+#### Commands
+
+Available under **Tools → DevGlobe**:
+
+| Command | Description |
+|---|---|
+| `Set Status Message` | Set your status message on the globe |
+| `Show Coding Time` | Show your coding time today |
+| `Open Globe` | Open [devglobe.app/space](https://devglobe.app/space) in your browser |
+| `Debug` | Toggle debug logging in `~/.devglobe/devglobe.log` |
+| `Open Log File…` | Open `~/.devglobe/devglobe.log` |
+| `Open Config File…` | Open `~/.devglobe/config.toml` |
+
+#### Compatibility
+
+- **Visual Studio 2022 (17.x)** and **Visual Studio 2026 (18.x)** — Windows only
+- .NET Framework 4.7.2
+
+---
+
### JetBrains
Compatible with **all JetBrains IDEs**: IntelliJ IDEA, WebStorm, PyCharm, GoLand, Rider, PhpStorm, CLion, RubyMine, DataGrip, Android Studio, RustRover.
@@ -361,7 +399,7 @@ hide_project_names = false # omit repo + branch (project-level hiding implies
**Globe visibility** (anonymous mode, repo sharing on the live globe, profile mode) is managed on [devglobe.app/dashboard/settings](https://devglobe.app/dashboard/settings) — not in the extension.
-**API keys** are stored in your OS keychain (VS Code SecretStorage, JetBrains PasswordSafe) or in a local config file under `~/.devglobe/` (Zed, NeoVim, Claude Code, Codex, OpenCode). Config files are created with `0600` permissions.
+**API keys** are stored in your OS keychain (VS Code SecretStorage, JetBrains PasswordSafe, Visual Studio Windows Credential Manager) or in a local config file under `~/.devglobe/` (Zed, NeoVim, Claude Code, Codex, OpenCode). Config files are created with `0600` permissions.
**Network:** HTTPS only (TLS 1.2+), no telemetry, no third-party trackers.
diff --git a/SECURITY.md b/SECURITY.md
index 60856a5..42ad718 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -6,8 +6,9 @@ Only the latest release of each extension receives security updates.
| Extension | Current version | Status |
|-----------|----------------|--------|
-| VS Code | 0.1.8 | Supported |
-| JetBrains | 0.1.7 | Supported |
+| VS Code | 2.1.0 | Supported |
+| JetBrains | 2.0.1 | Supported |
+| Visual Studio | 0.1.0 | Supported |
| Claude Code | 1.0.0 | Supported |
## Reporting a vulnerability
@@ -78,7 +79,7 @@ Key design decisions:
- **HTTPS only** — all network requests enforce TLS, no HTTP fallback
- **Minimal data** — only what is listed in [PRIVACY.md](PRIVACY.md) is sent; source code, file contents, and keystrokes are never accessed
-- **Secure key storage** — OS keychain on VS Code (SecretStorage) and JetBrains (PasswordSafe); `~/.devglobe/config.toml` (mode `0600`) on the other extensions
+- **Secure key storage** — OS keychain on VS Code (SecretStorage), JetBrains (PasswordSafe) and Visual Studio (Windows Credential Manager); `~/.devglobe/config.toml` (mode `0600`) on the other extensions
- **Content Security Policy** — VS Code webview uses a cryptographic nonce-based CSP
- **No telemetry** — no third-party analytics or tracking services
diff --git a/visualstudio-extension/.gitignore b/visualstudio-extension/.gitignore
new file mode 100644
index 0000000..a829bd3
--- /dev/null
+++ b/visualstudio-extension/.gitignore
@@ -0,0 +1,21 @@
+# .NET / MSBuild build output
+bin/
+obj/
+
+# Visual Studio user / cache files
+.vs/
+*.user
+*.suo
+*.userosscache
+*.sln.docstates
+
+# VSIX package output
+*.vsix
+
+# ReSharper / Rider
+_ReSharper*/
+*.DotSettings.user
+
+# Test results
+[Tt]est[Rr]esult*/
+*.trx
diff --git a/visualstudio-extension/CHANGELOG.md b/visualstudio-extension/CHANGELOG.md
new file mode 100644
index 0000000..8019c86
--- /dev/null
+++ b/visualstudio-extension/CHANGELOG.md
@@ -0,0 +1,17 @@
+# Changelog
+
+All notable changes to the DevGlobe Visual Studio extension are documented here.
+
+## [0.1.0] - 2026-06-23
+
+### Added
+
+- Initial release of DevGlobe for Visual Studio (VS 2022 / VS 2026).
+- DevGlobe tool window with **Login** and **Dashboard** views.
+- Live heartbeat tracking driven by the `devglobe-core` binary (heartbeat every 30s, auto-pause after 1 min of inactivity).
+- Activity detection: typing, active document, document open/save.
+- Language detection from the active document.
+- Status bar showing today's coding time.
+- Six commands under **Tools → DevGlobe**: Set Status Message, Show Coding Time, Open Globe, Debug, Open Log File, Open Config File.
+- API key stored in the Windows Credential Manager and written to `%USERPROFILE%\.devglobe\config.toml`.
+- One-time download of the `devglobe-core-win-x64.exe` binary from GitHub Releases on first launch, cached under `%LOCALAPPDATA%\DevGlobe\core`.
diff --git a/visualstudio-extension/DevGlobe.sln b/visualstudio-extension/DevGlobe.sln
new file mode 100644
index 0000000..c12fd08
--- /dev/null
+++ b/visualstudio-extension/DevGlobe.sln
@@ -0,0 +1,22 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.0.31903.59
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DevGlobe", "DevGlobe\DevGlobe.csproj", "{5F3A9C2E-7B14-4E8A-9D6F-1A2B3C4D5E6F}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {5F3A9C2E-7B14-4E8A-9D6F-1A2B3C4D5E6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {5F3A9C2E-7B14-4E8A-9D6F-1A2B3C4D5E6F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {5F3A9C2E-7B14-4E8A-9D6F-1A2B3C4D5E6F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {5F3A9C2E-7B14-4E8A-9D6F-1A2B3C4D5E6F}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+EndGlobal
diff --git a/visualstudio-extension/DevGlobe/ActivityTracker.cs b/visualstudio-extension/DevGlobe/ActivityTracker.cs
new file mode 100644
index 0000000..e3707e9
--- /dev/null
+++ b/visualstudio-extension/DevGlobe/ActivityTracker.cs
@@ -0,0 +1,332 @@
+using System;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.VisualStudio;
+using Microsoft.VisualStudio.ComponentModelHost;
+using Microsoft.VisualStudio.Editor;
+using Microsoft.VisualStudio.Shell;
+using Microsoft.VisualStudio.Shell.Interop;
+using Microsoft.VisualStudio.Text;
+using Microsoft.VisualStudio.TextManager.Interop;
+using Task = System.Threading.Tasks.Task;
+
+namespace DevGlobe
+{
+ ///
+ /// Detects editor activity and forwards it to the core (which debounces). Sources are the
+ /// Running Document Table (open/show/save) and ITextBuffer.Changed (keystrokes) of the
+ /// active document. Only real on-disk files are forwarded.
+ ///
+ public sealed class ActivityTracker : IVsRunningDocTableEvents3, IDisposable
+ {
+ private readonly IServiceProvider _serviceProvider;
+ private readonly CoreClient _coreClient;
+
+ private IVsRunningDocumentTable _rdt;
+ private IVsEditorAdaptersFactoryService _adapterFactory;
+ private uint _rdtCookie;
+
+ // Buffer of the active document currently watched for keystrokes.
+ private ITextBuffer _activeBuffer;
+ private bool _disposed;
+
+ public ActivityTracker(IServiceProvider serviceProvider, CoreClient coreClient)
+ {
+ _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
+ _coreClient = coreClient ?? throw new ArgumentNullException(nameof(coreClient));
+ }
+
+ /// Subscribes to the RDT and captures the current active document. Call on the UI thread.
+ public async Task InitializeAsync(CancellationToken ct)
+ {
+ await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(ct);
+
+ _rdt = _serviceProvider.GetService(typeof(SVsRunningDocumentTable)) as IVsRunningDocumentTable;
+ if (_rdt == null)
+ {
+ Log.Error("ActivityTracker: SVsRunningDocumentTable unavailable");
+ return;
+ }
+
+ // IVsEditorAdaptersFactoryService is a MEF export from the editor component.
+ var componentModel = _serviceProvider.GetService(typeof(SComponentModel))
+ as Microsoft.VisualStudio.ComponentModelHost.IComponentModel;
+ _adapterFactory = componentModel?.GetService();
+ if (_adapterFactory == null)
+ {
+ Log.Warn("ActivityTracker: IVsEditorAdaptersFactoryService unavailable; keystroke tracking disabled");
+ }
+
+ int hr = _rdt.AdviseRunningDocTableEvents(this, out _rdtCookie);
+ ErrorHandler.ThrowOnFailure(hr);
+ Log.Info("ActivityTracker: subscribed to RDT", new { cookie = _rdtCookie });
+
+ // Capture the document already active at startup.
+ TrackActiveDocument();
+ }
+
+ // ---- IVsRunningDocTableEvents3 ----
+
+ // Document opened or active tab changed.
+ public int OnBeforeDocumentWindowShow(uint docCookie, int fFirstShow, IVsWindowFrame pFrame)
+ {
+ ThreadHelper.ThrowIfNotOnUIThread();
+ HandleDocumentEvent(docCookie);
+ SubscribeToBuffer(docCookie);
+ return VSConstants.S_OK;
+ }
+
+ // Document saved.
+ public int OnAfterSave(uint docCookie)
+ {
+ ThreadHelper.ThrowIfNotOnUIThread();
+ HandleDocumentEvent(docCookie);
+ return VSConstants.S_OK;
+ }
+
+ // Rename or attribute change: the moniker may change, so re-forward.
+ public int OnAfterAttributeChangeEx(
+ uint docCookie,
+ uint grfAttribs,
+ IVsHierarchy pHierOld, uint itemidOld, string pszMkDocumentOld,
+ IVsHierarchy pHierNew, uint itemidNew, string pszMkDocumentNew)
+ {
+ ThreadHelper.ThrowIfNotOnUIThread();
+ if ((grfAttribs & (uint)__VSRDTATTRIB.RDTA_MkDocument) != 0)
+ {
+ HandleDocumentEvent(docCookie);
+ }
+ return VSConstants.S_OK;
+ }
+
+ // Unused RDT events.
+ public int OnAfterFirstDocumentLock(uint docCookie, uint dwRDTLockType, uint dwReadLocksRemaining, uint dwEditLocksRemaining)
+ => VSConstants.S_OK;
+ public int OnBeforeLastDocumentUnlock(uint docCookie, uint dwRDTLockType, uint dwReadLocksRemaining, uint dwEditLocksRemaining)
+ => VSConstants.S_OK;
+ public int OnAfterAttributeChange(uint docCookie, uint grfAttribs)
+ => VSConstants.S_OK;
+ public int OnAfterDocumentWindowHide(uint docCookie, IVsWindowFrame pFrame)
+ => VSConstants.S_OK;
+ public int OnBeforeSave(uint docCookie)
+ => VSConstants.S_OK;
+
+ ///
+ /// Forwards activity for an RDT document (open/show/save). Filters out non-files.
+ /// Language is derived from the buffer content-type if available, otherwise the extension.
+ ///
+ private void HandleDocumentEvent(uint docCookie)
+ {
+ ThreadHelper.ThrowIfNotOnUIThread();
+ try
+ {
+ string moniker = GetMoniker(docCookie);
+ if (!IsRealFile(moniker)) return;
+
+ ITextBuffer buffer = GetBufferForCookie(docCookie);
+ ReportActivity(moniker, buffer);
+ }
+ catch (Exception ex)
+ {
+ Log.Warn("ActivityTracker: HandleDocumentEvent failed", new { error = ex.Message });
+ }
+ }
+
+ ///
+ /// Forwards an activity to the core. Language comes from the content-type when a buffer
+ /// is available, otherwise from the file extension. The core debounces, so forward all.
+ ///
+ private void ReportActivity(string filePath, ITextBuffer buffer)
+ {
+ if (!IsRealFile(filePath)) return;
+
+ string languageKey = buffer != null
+ ? buffer.ContentType?.TypeName
+ : Path.GetExtension(filePath);
+
+ string language = string.IsNullOrEmpty(languageKey)
+ ? null
+ : LanguageMap.Map(languageKey);
+
+ _coreClient.Activity(filePath, language);
+ }
+
+ /// Returns true only for real on-disk files; non-file monikers are rejected.
+ private static bool IsRealFile(string moniker)
+ {
+ if (string.IsNullOrWhiteSpace(moniker)) return false;
+ // Non-file monikers are URIs/pseudo-paths (e.g. "ext://", "Solution.sln") or are not
+ // rooted absolute paths. Require a plausible absolute path.
+ try
+ {
+ if (moniker.IndexOfAny(Path.GetInvalidPathChars()) >= 0) return false;
+ if (!Path.IsPathRooted(moniker)) return false;
+ // No URI scheme (file:// is already resolved to a path by the RDT; reject the rest).
+ if (moniker.Contains("://")) return false;
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private string GetMoniker(uint docCookie)
+ {
+ ThreadHelper.ThrowIfNotOnUIThread();
+ if (_rdt == null) return null;
+
+ int hr = _rdt.GetDocumentInfo(
+ docCookie,
+ out _, out _, out _,
+ out string moniker,
+ out _, out _, out _);
+ return ErrorHandler.Succeeded(hr) ? moniker : null;
+ }
+
+ ///
+ /// Resolves the ITextBuffer for an RDT cookie via its IVsTextLines docData. Returns null
+ /// when the document is not a text editor (designer, etc.).
+ ///
+ private ITextBuffer GetBufferForCookie(uint docCookie)
+ {
+ ThreadHelper.ThrowIfNotOnUIThread();
+ if (_rdt == null || _adapterFactory == null) return null;
+
+ int hr = _rdt.GetDocumentInfo(
+ docCookie,
+ out _, out _, out _,
+ out _,
+ out _, out _,
+ out IntPtr docDataPtr);
+ if (ErrorHandler.Failed(hr) || docDataPtr == IntPtr.Zero) return null;
+
+ object docData = null;
+ try
+ {
+ docData = Marshal.GetObjectForIUnknown(docDataPtr);
+ if (docData is IVsTextLines textLines)
+ {
+ return _adapterFactory.GetDocumentBuffer(textLines);
+ }
+ if (docData is IVsTextBufferProvider bufferProvider
+ && ErrorHandler.Succeeded(bufferProvider.GetTextBuffer(out IVsTextLines lines))
+ && lines != null)
+ {
+ return _adapterFactory.GetDocumentBuffer(lines);
+ }
+ return null;
+ }
+ finally
+ {
+ if (docDataPtr != IntPtr.Zero) Marshal.Release(docDataPtr);
+ }
+ }
+
+ /// Captures the active document at startup and subscribes to it.
+ private void TrackActiveDocument()
+ {
+ ThreadHelper.ThrowIfNotOnUIThread();
+
+ var textManager = _serviceProvider.GetService(typeof(SVsTextManager)) as IVsTextManager;
+ if (textManager == null) return;
+
+ int hr = textManager.GetActiveView(1, null, out IVsTextView activeView);
+ if (ErrorHandler.Failed(hr) || activeView == null) return;
+ if (ErrorHandler.Failed(activeView.GetBuffer(out IVsTextLines lines)) || lines == null) return;
+ if (_adapterFactory == null) return;
+
+ ITextBuffer buffer = _adapterFactory.GetDocumentBuffer(lines);
+ if (buffer == null) return;
+
+ SetActiveBuffer(buffer);
+
+ // Forward an initial activity for the already-open document.
+ if (buffer.Properties.TryGetProperty(typeof(ITextDocument), out ITextDocument textDoc)
+ && textDoc != null)
+ {
+ ReportActivity(textDoc.FilePath, buffer);
+ }
+ }
+
+ /// Switches keystroke tracking to the ITextBuffer.Changed of the document for this cookie.
+ private void SubscribeToBuffer(uint docCookie)
+ {
+ ThreadHelper.ThrowIfNotOnUIThread();
+ ITextBuffer buffer = GetBufferForCookie(docCookie);
+ if (buffer == null) return;
+ SetActiveBuffer(buffer);
+ }
+
+ private void SetActiveBuffer(ITextBuffer buffer)
+ {
+ ThreadHelper.ThrowIfNotOnUIThread();
+ if (ReferenceEquals(buffer, _activeBuffer)) return;
+
+ if (_activeBuffer != null)
+ {
+ _activeBuffer.Changed -= OnActiveBufferChanged;
+ }
+
+ _activeBuffer = buffer;
+
+ if (_activeBuffer != null)
+ {
+ _activeBuffer.Changed += OnActiveBufferChanged;
+ }
+ }
+
+ /// Keystroke in the active document.
+ private void OnActiveBufferChanged(object sender, TextContentChangedEventArgs e)
+ {
+ // ITextBuffer.Changed may fire off the UI thread depending on the source; stay defensive.
+ try
+ {
+ if (sender is ITextBuffer buffer
+ && buffer.Properties.TryGetProperty(typeof(ITextDocument), out ITextDocument textDoc)
+ && textDoc != null)
+ {
+ ReportActivity(textDoc.FilePath, buffer);
+ }
+ }
+ catch (Exception ex)
+ {
+ Log.Warn("ActivityTracker: OnActiveBufferChanged failed", new { error = ex.Message });
+ }
+ }
+
+ // ---- Cleanup ----
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+
+ try
+ {
+ ThreadHelper.JoinableTaskFactory.Run(async () =>
+ {
+ await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync();
+
+ if (_activeBuffer != null)
+ {
+ _activeBuffer.Changed -= OnActiveBufferChanged;
+ _activeBuffer = null;
+ }
+
+ if (_rdt != null && _rdtCookie != 0)
+ {
+ _rdt.UnadviseRunningDocTableEvents(_rdtCookie);
+ _rdtCookie = 0;
+ }
+ });
+ }
+ catch (Exception ex)
+ {
+ Log.Warn("ActivityTracker: Dispose failed", new { error = ex.Message });
+ }
+ }
+ }
+}
diff --git a/visualstudio-extension/DevGlobe/Commands/CommandIds.cs b/visualstudio-extension/DevGlobe/Commands/CommandIds.cs
new file mode 100644
index 0000000..d20da49
--- /dev/null
+++ b/visualstudio-extension/DevGlobe/Commands/CommandIds.cs
@@ -0,0 +1,47 @@
+using System;
+using System.Threading.Tasks;
+using Microsoft.VisualStudio.Shell;
+using Task = System.Threading.Tasks.Task;
+
+namespace DevGlobe.Commands
+{
+ ///
+ /// CommandSet GUID and command IDs shared between the .vsct and the handlers.
+ /// Values must match the IDSymbol entries in DevGlobePackage.Commands.vsct exactly.
+ ///
+ internal static class CommandIds
+ {
+ public const string GuidDevGlobeCmdSetString = "7b5c1e40-9d36-40ac-bf8b-3c4d5e6f7081";
+ public static readonly Guid GuidDevGlobeCmdSet = new Guid(GuidDevGlobeCmdSetString);
+
+ public const int SetStatusCommandId = 0x0101;
+ public const int ShowCodingTimeCommandId = 0x0102;
+ public const int OpenGlobeCommandId = 0x0103;
+ public const int ToggleDebugCommandId = 0x0104;
+ public const int OpenLogFileCommandId = 0x0105;
+ public const int OpenConfigFileCommandId = 0x0106;
+ public const int OpenToolWindowCommandId = 0x0107;
+ }
+
+ ///
+ /// Initializes all DevGlobe commands. Called once from DevGlobePackage.InitializeAsync.
+ ///
+ internal static class DevGlobeCommands
+ {
+ public static async Task InitializeAllAsync(AsyncPackage package, Func coreClientProvider)
+ {
+ if (package == null) throw new ArgumentNullException(nameof(package));
+ if (coreClientProvider == null) throw new ArgumentNullException(nameof(coreClientProvider));
+
+ await SetStatusCommand.InitializeAsync(package, coreClientProvider);
+ await ShowCodingTimeCommand.InitializeAsync(package, coreClientProvider);
+ await OpenGlobeCommand.InitializeAsync(package);
+ await ToggleDebugCommand.InitializeAsync(package);
+ await OpenLogFileCommand.InitializeAsync(package);
+ await OpenConfigFileCommand.InitializeAsync(package);
+ await OpenToolWindowCommand.InitializeAsync(package);
+
+ Log.Info("DevGlobe commands initialized", new { count = 7 });
+ }
+ }
+}
diff --git a/visualstudio-extension/DevGlobe/Commands/OpenConfigFileCommand.cs b/visualstudio-extension/DevGlobe/Commands/OpenConfigFileCommand.cs
new file mode 100644
index 0000000..7d11713
--- /dev/null
+++ b/visualstudio-extension/DevGlobe/Commands/OpenConfigFileCommand.cs
@@ -0,0 +1,79 @@
+using System;
+using System.IO;
+using System.Threading.Tasks;
+using Microsoft.VisualStudio;
+using Microsoft.VisualStudio.Shell;
+using Microsoft.VisualStudio.Shell.Interop;
+using Task = System.Threading.Tasks.Task;
+
+namespace DevGlobe.Commands
+{
+ /// Opens the config.toml file.
+ internal static class OpenConfigFileCommand
+ {
+ private static AsyncPackage _package;
+
+ public static async Task InitializeAsync(AsyncPackage package)
+ {
+ _package = package ?? throw new ArgumentNullException(nameof(package));
+
+ await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(package.DisposalToken);
+ var commandService = (OleMenuCommandService)
+ await package.GetServiceAsync(typeof(System.ComponentModel.Design.IMenuCommandService));
+ if (commandService == null)
+ {
+ Log.Error("OpenConfigFileCommand: IMenuCommandService unavailable");
+ return;
+ }
+
+ var commandId = new System.ComponentModel.Design.CommandID(
+ CommandIds.GuidDevGlobeCmdSet, CommandIds.OpenConfigFileCommandId);
+ commandService.AddCommand(new OleMenuCommand(Execute, commandId));
+ }
+
+ private static void Execute(object sender, EventArgs e)
+ {
+ ThreadHelper.ThrowIfNotOnUIThread();
+ try
+ {
+ var path = DevGlobeConfig.ConfigPath;
+ if (!File.Exists(path))
+ {
+ VsShellUtilities.ShowMessageBox(
+ _package,
+ "DevGlobe: no config file yet. Run setup first (open the DevGlobe tool window and connect).",
+ "DevGlobe",
+ OLEMSGICON.OLEMSGICON_WARNING,
+ OLEMSGBUTTON.OLEMSGBUTTON_OK,
+ OLEMSGDEFBUTTON.OLEMSGDEFBUTTON_FIRST);
+ return;
+ }
+
+ OpenInEditor(path);
+ Log.Info("command openConfigFile", new { path });
+ }
+ catch (Exception ex)
+ {
+ Log.Error("command openConfigFile failed", new { error = ex.Message });
+ }
+ }
+
+ private static void OpenInEditor(string path)
+ {
+ ThreadHelper.ThrowIfNotOnUIThread();
+ try
+ {
+ VsShellUtilities.OpenDocument(
+ _package, path,
+ VSConstants.LOGVIEWID.Primary_guid,
+ out _, out _, out _);
+ }
+ catch (Exception ex)
+ {
+ Log.Warn("openConfigFile: in-IDE open failed, falling back to shell", new { error = ex.Message });
+ System.Diagnostics.Process.Start(
+ new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
+ }
+ }
+ }
+}
diff --git a/visualstudio-extension/DevGlobe/Commands/OpenGlobeCommand.cs b/visualstudio-extension/DevGlobe/Commands/OpenGlobeCommand.cs
new file mode 100644
index 0000000..2b78f82
--- /dev/null
+++ b/visualstudio-extension/DevGlobe/Commands/OpenGlobeCommand.cs
@@ -0,0 +1,45 @@
+using System;
+using System.Diagnostics;
+using System.Threading.Tasks;
+using Microsoft.VisualStudio.Shell;
+using Task = System.Threading.Tasks.Task;
+
+namespace DevGlobe.Commands
+{
+ /// Opens the globe in the default browser.
+ internal static class OpenGlobeCommand
+ {
+ private const string GlobeUrl = "https://devglobe.app/space";
+
+ public static async Task InitializeAsync(AsyncPackage package)
+ {
+ if (package == null) throw new ArgumentNullException(nameof(package));
+
+ await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(package.DisposalToken);
+ var commandService = (OleMenuCommandService)
+ await package.GetServiceAsync(typeof(System.ComponentModel.Design.IMenuCommandService));
+ if (commandService == null)
+ {
+ Log.Error("OpenGlobeCommand: IMenuCommandService unavailable");
+ return;
+ }
+
+ var commandId = new System.ComponentModel.Design.CommandID(
+ CommandIds.GuidDevGlobeCmdSet, CommandIds.OpenGlobeCommandId);
+ commandService.AddCommand(new OleMenuCommand(Execute, commandId));
+ }
+
+ private static void Execute(object sender, EventArgs e)
+ {
+ try
+ {
+ Process.Start(new ProcessStartInfo(GlobeUrl) { UseShellExecute = true });
+ Log.Info("command openGlobe", new { url = GlobeUrl });
+ }
+ catch (Exception ex)
+ {
+ Log.Error("command openGlobe failed", new { error = ex.Message });
+ }
+ }
+ }
+}
diff --git a/visualstudio-extension/DevGlobe/Commands/OpenLogFileCommand.cs b/visualstudio-extension/DevGlobe/Commands/OpenLogFileCommand.cs
new file mode 100644
index 0000000..1aeb560
--- /dev/null
+++ b/visualstudio-extension/DevGlobe/Commands/OpenLogFileCommand.cs
@@ -0,0 +1,80 @@
+using System;
+using System.IO;
+using System.Threading.Tasks;
+using Microsoft.VisualStudio;
+using Microsoft.VisualStudio.Shell;
+using Microsoft.VisualStudio.Shell.Interop;
+using Task = System.Threading.Tasks.Task;
+
+namespace DevGlobe.Commands
+{
+ /// Opens the log file.
+ internal static class OpenLogFileCommand
+ {
+ private static AsyncPackage _package;
+
+ public static async Task InitializeAsync(AsyncPackage package)
+ {
+ _package = package ?? throw new ArgumentNullException(nameof(package));
+
+ await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(package.DisposalToken);
+ var commandService = (OleMenuCommandService)
+ await package.GetServiceAsync(typeof(System.ComponentModel.Design.IMenuCommandService));
+ if (commandService == null)
+ {
+ Log.Error("OpenLogFileCommand: IMenuCommandService unavailable");
+ return;
+ }
+
+ var commandId = new System.ComponentModel.Design.CommandID(
+ CommandIds.GuidDevGlobeCmdSet, CommandIds.OpenLogFileCommandId);
+ commandService.AddCommand(new OleMenuCommand(Execute, commandId));
+ }
+
+ private static void Execute(object sender, EventArgs e)
+ {
+ ThreadHelper.ThrowIfNotOnUIThread();
+ try
+ {
+ var path = DevGlobeConfig.LogPath;
+ if (!File.Exists(path))
+ {
+ VsShellUtilities.ShowMessageBox(
+ _package,
+ "DevGlobe: log file is empty. Enable debug first (DevGlobe → Debug → Yes).",
+ "DevGlobe",
+ OLEMSGICON.OLEMSGICON_INFO,
+ OLEMSGBUTTON.OLEMSGBUTTON_OK,
+ OLEMSGDEFBUTTON.OLEMSGDEFBUTTON_FIRST);
+ return;
+ }
+
+ OpenInEditor(path);
+ Log.Info("command openLogFile", new { path });
+ }
+ catch (Exception ex)
+ {
+ Log.Error("command openLogFile failed", new { error = ex.Message });
+ }
+ }
+
+ private static void OpenInEditor(string path)
+ {
+ ThreadHelper.ThrowIfNotOnUIThread();
+ try
+ {
+ VsShellUtilities.OpenDocument(
+ _package, path,
+ VSConstants.LOGVIEWID.Primary_guid,
+ out _, out _, out _);
+ }
+ catch (Exception ex)
+ {
+ // Fall back to the external shell handler if the in-IDE open fails.
+ Log.Warn("openLogFile: in-IDE open failed, falling back to shell", new { error = ex.Message });
+ System.Diagnostics.Process.Start(
+ new System.Diagnostics.ProcessStartInfo(path) { UseShellExecute = true });
+ }
+ }
+ }
+}
diff --git a/visualstudio-extension/DevGlobe/Commands/OpenToolWindowCommand.cs b/visualstudio-extension/DevGlobe/Commands/OpenToolWindowCommand.cs
new file mode 100644
index 0000000..bb57128
--- /dev/null
+++ b/visualstudio-extension/DevGlobe/Commands/OpenToolWindowCommand.cs
@@ -0,0 +1,36 @@
+using System;
+using System.ComponentModel.Design;
+using System.Threading.Tasks;
+using DevGlobe.ToolWindow;
+using Microsoft.VisualStudio.Shell;
+using Task = System.Threading.Tasks.Task;
+
+namespace DevGlobe.Commands
+{
+ ///
+ /// Opens (or reopens) the DevGlobe tool window, loading the package on first use if needed.
+ ///
+ internal static class OpenToolWindowCommand
+ {
+ public static async Task InitializeAsync(AsyncPackage package)
+ {
+ await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(package.DisposalToken);
+
+ if (await package.GetServiceAsync(typeof(IMenuCommandService)) is OleMenuCommandService svc)
+ {
+ var id = new CommandID(CommandIds.GuidDevGlobeCmdSet, CommandIds.OpenToolWindowCommandId);
+ svc.AddCommand(new MenuCommand((s, e) => Execute(package), id));
+ }
+ }
+
+ private static void Execute(AsyncPackage package)
+ {
+ _ = package.JoinableTaskFactory.RunAsync(async () =>
+ {
+ await package.JoinableTaskFactory.SwitchToMainThreadAsync();
+ await package.ShowToolWindowAsync(
+ typeof(DevGlobeToolWindow), id: 0, create: true, cancellationToken: package.DisposalToken);
+ });
+ }
+ }
+}
diff --git a/visualstudio-extension/DevGlobe/Commands/SetStatusCommand.cs b/visualstudio-extension/DevGlobe/Commands/SetStatusCommand.cs
new file mode 100644
index 0000000..361ac81
--- /dev/null
+++ b/visualstudio-extension/DevGlobe/Commands/SetStatusCommand.cs
@@ -0,0 +1,150 @@
+using System;
+using System.Threading.Tasks;
+using System.Windows;
+using System.Windows.Controls;
+using Microsoft.VisualStudio.Shell;
+using Task = System.Threading.Tasks.Task;
+
+namespace DevGlobe.Commands
+{
+ ///
+ /// Prompts for a status message (max 100 chars) and calls CoreClient.SetStatus.
+ ///
+ internal static class SetStatusCommand
+ {
+ private static Func _coreClientProvider;
+
+ public static async Task InitializeAsync(AsyncPackage package, Func coreClientProvider)
+ {
+ _coreClientProvider = coreClientProvider ?? throw new ArgumentNullException(nameof(coreClientProvider));
+
+ await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(package.DisposalToken);
+ var commandService = (Microsoft.VisualStudio.Shell.OleMenuCommandService)
+ await package.GetServiceAsync(typeof(System.ComponentModel.Design.IMenuCommandService));
+ if (commandService == null)
+ {
+ Log.Error("SetStatusCommand: IMenuCommandService unavailable");
+ return;
+ }
+
+ var commandId = new System.ComponentModel.Design.CommandID(
+ CommandIds.GuidDevGlobeCmdSet, CommandIds.SetStatusCommandId);
+ var menuItem = new Microsoft.VisualStudio.Shell.OleMenuCommand(Execute, commandId);
+ commandService.AddCommand(menuItem);
+ }
+
+ private static void Execute(object sender, EventArgs e)
+ {
+ ThreadHelper.ThrowIfNotOnUIThread();
+ try
+ {
+ // No API key configured: nothing to do.
+ if (string.IsNullOrEmpty(DevGlobeConfig.ReadApiKey()))
+ {
+ Log.Info("command setStatus skipped: no api key");
+ return;
+ }
+
+ var dialog = new StatusInputDialog();
+ var owner = Application.Current?.MainWindow;
+ if (owner != null)
+ {
+ dialog.Owner = owner;
+ }
+
+ var ok = dialog.ShowDialog();
+ if (ok != true)
+ {
+ // Cancelled: do nothing.
+ return;
+ }
+
+ var message = dialog.Message ?? string.Empty;
+ if (message.Length > 100)
+ {
+ message = message.Substring(0, 100);
+ }
+
+ Log.Info("command setStatus", new { length = message.Length, hasKey = true });
+ _coreClientProvider()?.SetStatus(message);
+ }
+ catch (Exception ex)
+ {
+ Log.Error("command setStatus failed", new { error = ex.Message });
+ }
+ }
+ }
+
+ ///
+ /// Modal WPF input dialog used by SetStatusCommand, limited to 100 characters.
+ ///
+ internal sealed class StatusInputDialog : Window
+ {
+ private readonly TextBox _input;
+
+ /// Entered text (null if cancelled).
+ public string Message { get; private set; }
+
+ public StatusInputDialog()
+ {
+ Title = "DevGlobe — Set Status Message";
+ Width = 420;
+ Height = 170;
+ WindowStartupLocation = WindowStartupLocation.CenterOwner;
+ ResizeMode = ResizeMode.NoResize;
+ ShowInTaskbar = false;
+
+ var root = new StackPanel { Margin = new Thickness(16) };
+
+ root.Children.Add(new TextBlock
+ {
+ Text = "What are you working on?",
+ Margin = new Thickness(0, 0, 0, 6)
+ });
+
+ _input = new TextBox
+ {
+ MaxLength = 100,
+ Margin = new Thickness(0, 0, 0, 4)
+ };
+ _input.KeyDown += (s, e) =>
+ {
+ if (e.Key == System.Windows.Input.Key.Enter) { Confirm(); }
+ else if (e.Key == System.Windows.Input.Key.Escape) { DialogResult = false; }
+ };
+ root.Children.Add(_input);
+
+ var counter = new TextBlock
+ {
+ Text = "0 / 100",
+ FontSize = 11,
+ Opacity = 0.7,
+ HorizontalAlignment = HorizontalAlignment.Right,
+ Margin = new Thickness(0, 0, 0, 10)
+ };
+ _input.TextChanged += (s, e) => counter.Text = $"{_input.Text.Length} / 100";
+ root.Children.Add(counter);
+
+ var buttons = new StackPanel
+ {
+ Orientation = Orientation.Horizontal,
+ HorizontalAlignment = HorizontalAlignment.Right
+ };
+ var okButton = new Button { Content = "Set", Width = 80, IsDefault = true, Margin = new Thickness(0, 0, 8, 0) };
+ okButton.Click += (s, e) => Confirm();
+ var cancelButton = new Button { Content = "Cancel", Width = 80, IsCancel = true };
+ buttons.Children.Add(okButton);
+ buttons.Children.Add(cancelButton);
+ root.Children.Add(buttons);
+
+ Content = root;
+ Loaded += (s, e) => _input.Focus();
+ }
+
+ private void Confirm()
+ {
+ Message = _input.Text ?? string.Empty;
+ DialogResult = true;
+ }
+ }
+}
diff --git a/visualstudio-extension/DevGlobe/Commands/ShowCodingTimeCommand.cs b/visualstudio-extension/DevGlobe/Commands/ShowCodingTimeCommand.cs
new file mode 100644
index 0000000..21866ef
--- /dev/null
+++ b/visualstudio-extension/DevGlobe/Commands/ShowCodingTimeCommand.cs
@@ -0,0 +1,60 @@
+using System;
+using System.Threading.Tasks;
+using Microsoft.VisualStudio.Shell;
+using Microsoft.VisualStudio.Shell.Interop;
+using Task = System.Threading.Tasks.Task;
+
+namespace DevGlobe.Commands
+{
+ /// Shows today's coding time.
+ internal static class ShowCodingTimeCommand
+ {
+ private static Func _coreClientProvider;
+ private static AsyncPackage _package;
+
+ public static async Task InitializeAsync(AsyncPackage package, Func coreClientProvider)
+ {
+ _package = package ?? throw new ArgumentNullException(nameof(package));
+ _coreClientProvider = coreClientProvider ?? throw new ArgumentNullException(nameof(coreClientProvider));
+
+ await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(package.DisposalToken);
+ var commandService = (OleMenuCommandService)
+ await package.GetServiceAsync(typeof(System.ComponentModel.Design.IMenuCommandService));
+ if (commandService == null)
+ {
+ Log.Error("ShowCodingTimeCommand: IMenuCommandService unavailable");
+ return;
+ }
+
+ var commandId = new System.ComponentModel.Design.CommandID(
+ CommandIds.GuidDevGlobeCmdSet, CommandIds.ShowCodingTimeCommandId);
+ commandService.AddCommand(new OleMenuCommand(Execute, commandId));
+ }
+
+ private static void Execute(object sender, EventArgs e)
+ {
+ ThreadHelper.ThrowIfNotOnUIThread();
+ try
+ {
+ var client = _coreClientProvider();
+ var codingTime = client != null ? client.GetState().CodingTime : "0m";
+ if (string.IsNullOrEmpty(codingTime))
+ {
+ codingTime = "0m";
+ }
+
+ VsShellUtilities.ShowMessageBox(
+ _package,
+ $"DevGlobe: {codingTime} today",
+ "DevGlobe",
+ OLEMSGICON.OLEMSGICON_INFO,
+ OLEMSGBUTTON.OLEMSGBUTTON_OK,
+ OLEMSGDEFBUTTON.OLEMSGDEFBUTTON_FIRST);
+ }
+ catch (Exception ex)
+ {
+ Log.Error("command showCodingTime failed", new { error = ex.Message });
+ }
+ }
+ }
+}
diff --git a/visualstudio-extension/DevGlobe/Commands/ToggleDebugCommand.cs b/visualstudio-extension/DevGlobe/Commands/ToggleDebugCommand.cs
new file mode 100644
index 0000000..990e2ec
--- /dev/null
+++ b/visualstudio-extension/DevGlobe/Commands/ToggleDebugCommand.cs
@@ -0,0 +1,74 @@
+using System;
+using System.Threading.Tasks;
+using Microsoft.VisualStudio.Shell;
+using Microsoft.VisualStudio.Shell.Interop;
+using Task = System.Threading.Tasks.Task;
+
+namespace DevGlobe.Commands
+{
+ /// Toggles debug mode in config.toml.
+ internal static class ToggleDebugCommand
+ {
+ // VSConstants.MessageBoxResult.IDYES value, inlined to avoid the dependency.
+ private const int IDYES = 6;
+ private static AsyncPackage _package;
+
+ public static async Task InitializeAsync(AsyncPackage package)
+ {
+ _package = package ?? throw new ArgumentNullException(nameof(package));
+
+ await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync(package.DisposalToken);
+ var commandService = (OleMenuCommandService)
+ await package.GetServiceAsync(typeof(System.ComponentModel.Design.IMenuCommandService));
+ if (commandService == null)
+ {
+ Log.Error("ToggleDebugCommand: IMenuCommandService unavailable");
+ return;
+ }
+
+ var commandId = new System.ComponentModel.Design.CommandID(
+ CommandIds.GuidDevGlobeCmdSet, CommandIds.ToggleDebugCommandId);
+ commandService.AddCommand(new OleMenuCommand(Execute, commandId));
+ }
+
+ private static void Execute(object sender, EventArgs e)
+ {
+ ThreadHelper.ThrowIfNotOnUIThread();
+ try
+ {
+ var current = DevGlobeConfig.IsDebugEnabled();
+
+ var result = VsShellUtilities.ShowMessageBox(
+ _package,
+ $"Enable DevGlobe debug logging?\n\nCurrent value: {(current ? "enabled" : "disabled")}.\n" +
+ "Yes = enable, No = disable.",
+ "DevGlobe Debug",
+ OLEMSGICON.OLEMSGICON_QUERY,
+ OLEMSGBUTTON.OLEMSGBUTTON_YESNOCANCEL,
+ OLEMSGDEFBUTTON.OLEMSGDEFBUTTON_FIRST);
+
+ // Cancel or close: do nothing.
+ if (result != IDYES && result != /* IDNO */ 7)
+ {
+ return;
+ }
+
+ var enabled = result == IDYES;
+ DevGlobeConfig.SetDebug(enabled);
+ Log.Info("command toggleDebug", new { enabled });
+
+ VsShellUtilities.ShowMessageBox(
+ _package,
+ $"DevGlobe: debug {(enabled ? "enabled" : "disabled")}. Restart tracking to apply.",
+ "DevGlobe",
+ OLEMSGICON.OLEMSGICON_INFO,
+ OLEMSGBUTTON.OLEMSGBUTTON_OK,
+ OLEMSGDEFBUTTON.OLEMSGDEFBUTTON_FIRST);
+ }
+ catch (Exception ex)
+ {
+ Log.Error("command toggleDebug failed", new { error = ex.Message });
+ }
+ }
+ }
+}
diff --git a/visualstudio-extension/DevGlobe/CoreBootstrap.cs b/visualstudio-extension/DevGlobe/CoreBootstrap.cs
new file mode 100644
index 0000000..21444f0
--- /dev/null
+++ b/visualstudio-extension/DevGlobe/CoreBootstrap.cs
@@ -0,0 +1,244 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Threading;
+using System.Threading.Tasks;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+
+namespace DevGlobe
+{
+ ///
+ /// Bootstraps the core binary, caching it at
+ /// %LOCALAPPDATA%\DevGlobe\core\<CoreTag>\devglobe-core-win-x64.exe.
+ /// If absent, resolves the pinned GitHub release, downloads the asset, writes the cache,
+ /// and purges old versions.
+ ///
+ public static class CoreBootstrap
+ {
+ public const string CoreRepo = "Nako0/devglobe-extension";
+ // Pinned; bump manually when a new core is published.
+ public const string CoreTag = "core-v2.0.1";
+ public const string AssetName = "devglobe-core-win-x64.exe";
+
+ // Cache subtree under %LOCALAPPDATA%.
+ private const string CacheVendorDir = "DevGlobe";
+ private const string CacheCoreDir = "core";
+
+ // Combined timeout for download plus metadata; the binary is tens of MB.
+ private static readonly TimeSpan HttpTimeout = TimeSpan.FromMinutes(5);
+
+ // Single reused HttpClient to avoid socket exhaustion.
+ private static readonly HttpClient Http = CreateHttpClient();
+
+ private static HttpClient CreateHttpClient()
+ {
+ var client = new HttpClient { Timeout = HttpTimeout };
+ // GitHub requires a User-Agent; Accept json for the releases API.
+ client.DefaultRequestHeaders.UserAgent.ParseAdd("DevGlobe-VisualStudio");
+ client.DefaultRequestHeaders.Accept.Add(
+ new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
+ return client;
+ }
+
+ ///
+ /// Resolves the cache path <rootLocalAppData>\DevGlobe\core\<CoreTag>\<AssetName>.
+ ///
+ public static string ResolveCachePath(string rootLocalAppData)
+ {
+ if (string.IsNullOrEmpty(rootLocalAppData))
+ throw new ArgumentException("rootLocalAppData must not be empty", nameof(rootLocalAppData));
+
+ return Path.Combine(rootLocalAppData, CacheVendorDir, CacheCoreDir, CoreTag, AssetName);
+ }
+
+ ///
+ /// Ensures the core binary is cached and returns its absolute path, downloading from
+ /// GitHub Releases on first run. Throws with a clear message on failure.
+ ///
+ public static async Task EnsureBinaryAsync(CancellationToken ct)
+ {
+ var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
+ if (string.IsNullOrEmpty(localAppData))
+ throw new InvalidOperationException("Could not resolve %LOCALAPPDATA% for the core cache.");
+
+ var cachePath = ResolveCachePath(localAppData);
+
+ // Fast path: already cached.
+ if (File.Exists(cachePath))
+ {
+ Log.Info("CoreBootstrap: binary already cached", new { path = cachePath });
+ return cachePath;
+ }
+
+ Log.Info("CoreBootstrap: binary missing, downloading from GitHub",
+ new { repo = CoreRepo, tag = CoreTag, asset = AssetName });
+
+ var versionDir = Path.GetDirectoryName(cachePath)!;
+ Directory.CreateDirectory(versionDir);
+
+ // Purge old versions before writing the new one (best-effort).
+ PurgeOldVersions(Path.Combine(localAppData, CacheVendorDir, CacheCoreDir), keepDir: versionDir);
+
+ var downloadUrl = await ResolveAssetUrlAsync(ct).ConfigureAwait(false);
+ await DownloadToAsync(downloadUrl, cachePath, ct).ConfigureAwait(false);
+
+ Log.Info("CoreBootstrap: binary ready", new { path = cachePath });
+ return cachePath;
+ }
+
+ ///
+ /// Queries the GitHub releases API for the pinned tag and returns the
+ /// browser_download_url of AssetName.
+ ///
+ private static async Task ResolveAssetUrlAsync(CancellationToken ct)
+ {
+ var apiUrl = $"https://api.github.com/repos/{CoreRepo}/releases/tags/{CoreTag}";
+
+ string json;
+ try
+ {
+ using var resp = await Http.GetAsync(apiUrl, ct).ConfigureAwait(false);
+ if (!resp.IsSuccessStatusCode)
+ {
+ throw new InvalidOperationException(
+ $"GitHub API returned {(int)resp.StatusCode} {resp.ReasonPhrase} for release '{CoreTag}'.");
+ }
+ json = await resp.Content.ReadAsStringAsync().ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception ex) when (ex is HttpRequestException || ex is TaskCanceledException)
+ {
+ throw new InvalidOperationException(
+ $"Failed to reach GitHub to resolve the DevGlobe core release '{CoreTag}'. " +
+ "Check your network connection and try again.", ex);
+ }
+
+ string? downloadUrl = null;
+ try
+ {
+ var root = JObject.Parse(json);
+ if (root["assets"] is JArray assets)
+ {
+ foreach (var asset in assets)
+ {
+ if (string.Equals((string?)asset["name"], AssetName, StringComparison.Ordinal))
+ {
+ downloadUrl = (string?)asset["browser_download_url"];
+ break;
+ }
+ }
+ }
+ }
+ catch (JsonException ex)
+ {
+ throw new InvalidOperationException(
+ $"Could not parse the GitHub release metadata for '{CoreTag}'.", ex);
+ }
+
+ if (string.IsNullOrEmpty(downloadUrl))
+ {
+ throw new InvalidOperationException(
+ $"Asset '{AssetName}' was not found in the GitHub release '{CoreTag}'.");
+ }
+
+ return downloadUrl!;
+ }
+
+ ///
+ /// Downloads the asset to a temp file then moves it into destPath, avoiding a
+ /// half-written binary if the IDE closes mid-download.
+ ///
+ private static async Task DownloadToAsync(string url, string destPath, CancellationToken ct)
+ {
+ var tmpPath = destPath + ".download";
+
+ try
+ {
+ using (var resp = await Http
+ .GetAsync(url, HttpCompletionOption.ResponseHeadersRead, ct)
+ .ConfigureAwait(false))
+ {
+ if (!resp.IsSuccessStatusCode)
+ {
+ throw new InvalidOperationException(
+ $"Download of '{AssetName}' failed with {(int)resp.StatusCode} {resp.ReasonPhrase}.");
+ }
+
+ using var src = await resp.Content.ReadAsStreamAsync().ConfigureAwait(false);
+ using var dst = new FileStream(
+ tmpPath, FileMode.Create, FileAccess.Write, FileShare.None,
+ bufferSize: 81920, useAsync: true);
+ await src.CopyToAsync(dst, 81920, ct).ConfigureAwait(false);
+ }
+
+ if (File.Exists(destPath))
+ File.Delete(destPath);
+ File.Move(tmpPath, destPath);
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ TryDelete(tmpPath);
+ throw;
+ }
+ catch (Exception ex) when (ex is HttpRequestException || ex is TaskCanceledException || ex is IOException)
+ {
+ TryDelete(tmpPath);
+ throw new InvalidOperationException(
+ $"Failed to download the DevGlobe core binary from '{url}'. " +
+ "Check your network connection and try again.", ex);
+ }
+ }
+
+ ///
+ /// Deletes version folders other than keepDir under the core cache dir (best-effort:
+ /// an error here must not fail the bootstrap).
+ ///
+ private static void PurgeOldVersions(string coreCacheDir, string keepDir)
+ {
+ try
+ {
+ if (!Directory.Exists(coreCacheDir))
+ return;
+
+ var keepFull = Path.GetFullPath(keepDir);
+ foreach (var dir in Directory.GetDirectories(coreCacheDir))
+ {
+ if (string.Equals(Path.GetFullPath(dir), keepFull, StringComparison.OrdinalIgnoreCase))
+ continue;
+ try
+ {
+ Directory.Delete(dir, recursive: true);
+ Log.Info("CoreBootstrap: purged old core version", new { dir });
+ }
+ catch (Exception ex)
+ {
+ Log.Warn("CoreBootstrap: could not purge old core version", new { dir, error = ex.Message });
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Log.Warn("CoreBootstrap: purge step failed", new { error = ex.Message });
+ }
+ }
+
+ private static void TryDelete(string path)
+ {
+ try
+ {
+ if (File.Exists(path))
+ File.Delete(path);
+ }
+ catch
+ {
+ // best-effort
+ }
+ }
+ }
+}
diff --git a/visualstudio-extension/DevGlobe/CoreClient.cs b/visualstudio-extension/DevGlobe/CoreClient.cs
new file mode 100644
index 0000000..8666a39
--- /dev/null
+++ b/visualstudio-extension/DevGlobe/CoreClient.cs
@@ -0,0 +1,428 @@
+using System;
+using System.Diagnostics;
+using System.Text;
+using Newtonsoft.Json;
+
+namespace DevGlobe
+{
+ ///
+ /// Client for the devglobe-core daemon: launches "devglobe-core-win-x64.exe daemon",
+ /// communicates via JSON-lines over stdin/stdout, dispatches events to TrackerState
+ /// and onStateChange, and restarts the process if it dies.
+ ///
+ public sealed class CoreClient : IDisposable
+ {
+ private readonly string _corePath;
+ private readonly Action _onStateChange;
+ private readonly string _pluginVersion;
+ private readonly Action _onInvalidApiKey;
+
+ // Optional UI hooks. No-op by default so CoreClient stays decoupled from the VSSDK.
+ private readonly Action _notifyInfo;
+ private readonly Action _notifyError;
+ private readonly Action _offerReconnect;
+ private DevGlobeStatusBar? _statusBar;
+
+ private readonly object _gate = new object();
+ private Process? _proc;
+ private TrackerState _state = NewDefaultState();
+ private bool _disposed;
+
+ public CoreClient(
+ string corePath,
+ Action onStateChange,
+ string pluginVersion,
+ Action onInvalidApiKey)
+ : this(corePath, onStateChange, pluginVersion, onInvalidApiKey,
+ statusBar: null, notifyInfo: null, notifyError: null, offerReconnect: null)
+ {
+ }
+
+ /// Extended constructor that injects the status bar and UI hooks.
+ public CoreClient(
+ string corePath,
+ Action onStateChange,
+ string pluginVersion,
+ Action onInvalidApiKey,
+ DevGlobeStatusBar? statusBar,
+ Action? notifyInfo,
+ Action? notifyError,
+ Action? offerReconnect)
+ {
+ _corePath = corePath;
+ _onStateChange = onStateChange ?? (_ => { });
+ _pluginVersion = pluginVersion;
+ _onInvalidApiKey = onInvalidApiKey ?? (() => { });
+ _statusBar = statusBar;
+ _notifyInfo = notifyInfo ?? (_ => { });
+ _notifyError = notifyError ?? (_ => { });
+ _offerReconnect = offerReconnect ?? (() => { });
+ }
+
+ private static TrackerState NewDefaultState() => new TrackerState
+ {
+ Configured = false,
+ Tracking = false,
+ CodingTime = "0m",
+ TodaySeconds = 0,
+ Language = null,
+ Offline = false,
+ };
+
+ public TrackerState GetState()
+ {
+ lock (_gate)
+ {
+ // Defensive copy.
+ return new TrackerState
+ {
+ Configured = _state.Configured,
+ Tracking = _state.Tracking,
+ CodingTime = _state.CodingTime,
+ TodaySeconds = _state.TodaySeconds,
+ Language = _state.Language,
+ Offline = _state.Offline,
+ };
+ }
+ }
+
+ /// Serializes msg as JSON + '\n' and writes it to the daemon's stdin.
+ private void Send(object msg)
+ {
+ Process? proc;
+ lock (_gate) { proc = _proc; }
+ if (proc == null) return;
+ try
+ {
+ if (!proc.HasExited && proc.StandardInput.BaseStream.CanWrite)
+ {
+ string json = JsonConvert.SerializeObject(msg);
+ proc.StandardInput.Write(json);
+ proc.StandardInput.Write('\n');
+ proc.StandardInput.Flush();
+ }
+ }
+ catch (Exception ex)
+ {
+ Log.Warn("Failed to write to core stdin", new { error = ex.Message });
+ }
+ }
+
+ /// (Re)starts the daemon process if it is not running.
+ private void EnsureProcess()
+ {
+ lock (_gate)
+ {
+ if (_proc != null && !_proc.HasExited) return;
+
+ var psi = new ProcessStartInfo
+ {
+ FileName = _corePath,
+ Arguments = "daemon",
+ RedirectStandardInput = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ StandardOutputEncoding = Encoding.UTF8,
+ StandardErrorEncoding = Encoding.UTF8,
+ };
+
+ Process proc;
+ try
+ {
+ proc = new Process { StartInfo = psi, EnableRaisingEvents = true };
+ proc.OutputDataReceived += OnOutputLine;
+ proc.ErrorDataReceived += OnErrorLine;
+ proc.Exited += OnProcessExited;
+ if (!proc.Start())
+ {
+ HandleProcessFailure("Failed to start devglobe-core (Process.Start returned false).");
+ return;
+ }
+ }
+ catch (Exception ex)
+ {
+ HandleProcessFailure($"Failed to start devglobe-core: {ex.Message}");
+ return;
+ }
+
+ _proc = proc;
+ proc.BeginOutputReadLine();
+ proc.BeginErrorReadLine();
+ Log.Info("core daemon started", new { path = _corePath });
+ }
+ }
+
+ private void OnErrorLine(object sender, DataReceivedEventArgs e)
+ {
+ if (!string.IsNullOrEmpty(e.Data))
+ {
+ Log.Warn("core stderr", new { line = e.Data });
+ }
+ }
+
+ private void OnProcessExited(object? sender, EventArgs e)
+ {
+ bool wasTracking;
+ lock (_gate)
+ {
+ int code = -1;
+ try { code = _proc?.ExitCode ?? -1; } catch { }
+ Log.Info("core exited", new { code });
+ wasTracking = _state.Tracking;
+ _proc = null;
+ if (wasTracking) _state.Tracking = false;
+ }
+
+ if (_disposed) return;
+
+ if (wasTracking)
+ {
+ _onStateChange(GetState());
+ _notifyError("Tracking stopped — devglobe-core exited unexpectedly.");
+ }
+ }
+
+ private void HandleProcessFailure(string message)
+ {
+ Log.Error(message);
+ bool wasTracking;
+ lock (_gate)
+ {
+ _proc = null;
+ wasTracking = _state.Tracking;
+ if (wasTracking) _state.Tracking = false;
+ }
+ if (wasTracking) _onStateChange(GetState());
+ _notifyError(message);
+ }
+
+ private void OnOutputLine(object sender, DataReceivedEventArgs e)
+ {
+ if (e.Data == null) return; // null signals the stream is closed
+ HandleLineResult result;
+ lock (_gate)
+ {
+ result = HandleLine(e.Data, _state);
+ }
+ ApplyEffects(result);
+ }
+
+ ///
+ /// Pure logic: parses one JSON line, mutates the TrackerState and returns the side
+ /// effects to trigger. Touches neither the process nor the VSSDK UI.
+ ///
+ internal static HandleLineResult HandleLine(string line, TrackerState state)
+ {
+ var result = new HandleLineResult();
+ if (string.IsNullOrWhiteSpace(line)) return result;
+
+ CoreEvent? evt;
+ try
+ {
+ evt = JsonConvert.DeserializeObject(line);
+ }
+ catch (JsonException)
+ {
+ return result; // non-JSON line is ignored
+ }
+ if (evt?.Event == null) return result;
+
+ switch (evt.Event)
+ {
+ case "ready":
+ state.Configured = evt.Data?.Configured ?? false;
+ result.StateChanged = true;
+ break;
+
+ case "not_configured":
+ state.Configured = false;
+ state.Tracking = false;
+ result.StateChanged = true;
+ break;
+
+ case "invalid_api_key":
+ state.Configured = false;
+ state.Tracking = false;
+ result.StateChanged = true;
+ result.InvalidApiKey = true;
+ break;
+
+ case "heartbeat_ok":
+ state.TodaySeconds = evt.Data?.TodaySeconds ?? 0;
+ state.Language = evt.Data?.Language;
+ state.Tracking = true;
+ state.Offline = false;
+ result.UpdateStatusBar = true;
+ result.StatusBarSeconds = state.TodaySeconds;
+ result.StateChanged = true;
+ break;
+
+ case "offline":
+ state.Offline = true;
+ result.StateChanged = true;
+ break;
+
+ case "online":
+ state.Offline = false;
+ result.StateChanged = true;
+ break;
+
+ case "status_ok":
+ result.StatusOk = true;
+ break;
+
+ case "status_error":
+ result.StatusError = true;
+ result.StatusErrorMessage = evt.Data?.Message;
+ break;
+
+ // unknown event is ignored (forward-compatible)
+ }
+
+ return result;
+ }
+
+ /// Executes the side effects described by HandleLine.
+ private void ApplyEffects(HandleLineResult result)
+ {
+ if (result.UpdateStatusBar)
+ {
+ _statusBar?.UpdateTime(result.StatusBarSeconds);
+ lock (_gate)
+ {
+ _state.CodingTime = FormatCodingTime(result.StatusBarSeconds);
+ }
+ }
+
+ if (result.StateChanged)
+ {
+ _onStateChange(GetState());
+ }
+
+ if (result.InvalidApiKey)
+ {
+ Log.Warn("core reported invalid API key");
+ _offerReconnect();
+ _onInvalidApiKey();
+ }
+
+ if (result.StatusOk)
+ {
+ Log.Info("core status ok");
+ _notifyInfo("Status updated");
+ }
+
+ if (result.StatusError)
+ {
+ Log.Warn("core status error", new { message = result.StatusErrorMessage });
+ _notifyError(result.StatusErrorMessage ?? "Status update failed");
+ }
+ }
+
+ /// Formats seconds as "2h 15m" or "15m".
+ private static string FormatCodingTime(long todaySeconds)
+ {
+ long h = todaySeconds / 3600;
+ long m = (todaySeconds % 3600) / 60;
+ return h > 0 ? $"{h}h {m}m" : $"{m}m";
+ }
+
+ public void Init()
+ {
+ EnsureProcess();
+ Send(new
+ {
+ method = "init",
+ @params = new
+ {
+ plugin_version = _pluginVersion,
+ editor = EditorInfo.DetectEditor(),
+ },
+ });
+ }
+
+ public void Start()
+ {
+ lock (_gate) { _state.Tracking = true; }
+ Send(new { method = "resume" });
+ _onStateChange(GetState());
+ }
+
+ public void Pause()
+ {
+ lock (_gate) { _state.Tracking = false; }
+ Send(new { method = "pause" });
+ _statusBar?.Hide();
+ _onStateChange(GetState());
+ }
+
+ public void Activity(string filePath, string? language)
+ {
+ if (string.IsNullOrEmpty(language))
+ {
+ Send(new { method = "activity", @params = new { file = filePath } });
+ }
+ else
+ {
+ Send(new { method = "activity", @params = new { file = filePath, language } });
+ }
+ }
+
+ public void SetStatus(string message)
+ {
+ Log.Info("core setStatus requested", new { length = message?.Length ?? 0 });
+ Send(new { method = "set_status", @params = new { message } });
+ }
+
+ public void Reset()
+ {
+ TearDownProcess();
+ lock (_gate) { _state = NewDefaultState(); }
+ _statusBar?.Hide();
+ _onStateChange(GetState());
+ }
+
+ private void TearDownProcess()
+ {
+ Send(new { method = "shutdown" });
+ Process? proc;
+ lock (_gate)
+ {
+ proc = _proc;
+ _proc = null;
+ }
+ if (proc == null) return;
+ try
+ {
+ proc.OutputDataReceived -= OnOutputLine;
+ proc.ErrorDataReceived -= OnErrorLine;
+ proc.Exited -= OnProcessExited;
+ if (!proc.HasExited)
+ {
+ // Allow a graceful shutdown, then kill.
+ if (!proc.WaitForExit(500))
+ {
+ proc.Kill();
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Log.Warn("Error during core teardown", new { error = ex.Message });
+ }
+ finally
+ {
+ proc.Dispose();
+ }
+ }
+
+ public void Dispose()
+ {
+ if (_disposed) return;
+ _disposed = true;
+ TearDownProcess();
+ }
+ }
+}
diff --git a/visualstudio-extension/DevGlobe/CoreEvents.cs b/visualstudio-extension/DevGlobe/CoreEvents.cs
new file mode 100644
index 0000000..c1d7e1c
--- /dev/null
+++ b/visualstudio-extension/DevGlobe/CoreEvents.cs
@@ -0,0 +1,55 @@
+using Newtonsoft.Json;
+
+namespace DevGlobe
+{
+ ///
+ /// Deserialization DTO for one daemon stdout line: { "event": "...", "data": { ... } }.
+ ///
+ internal sealed class CoreEvent
+ {
+ [JsonProperty("event")]
+ public string? Event { get; set; }
+
+ [JsonProperty("data")]
+ public CoreEventData? Data { get; set; }
+ }
+
+ internal sealed class CoreEventData
+ {
+ [JsonProperty("configured")]
+ public bool Configured { get; set; }
+
+ [JsonProperty("today_seconds")]
+ public long TodaySeconds { get; set; }
+
+ [JsonProperty("language")]
+ public string? Language { get; set; }
+
+ [JsonProperty("message")]
+ public string? Message { get; set; }
+ }
+
+ ///
+ /// Side effects for CoreClient to apply after HandleLine. HandleLine only mutates the
+ /// TrackerState and fills this descriptor; CoreClient then runs the effects.
+ ///
+ internal sealed class HandleLineResult
+ {
+ /// State changed: call onStateChange.
+ public bool StateChanged { get; set; }
+
+ /// heartbeat_ok received: update the status bar with these seconds.
+ public bool UpdateStatusBar { get; set; }
+ public long StatusBarSeconds { get; set; }
+
+ /// invalid_api_key received: warn the user and call onInvalidApiKey.
+ public bool InvalidApiKey { get; set; }
+
+ /// status_ok received: notify the user.
+ public bool StatusOk { get; set; }
+
+ /// status_error received: warn the user with this message.
+ public bool StatusError { get; set; }
+ public string? StatusErrorMessage { get; set; }
+ }
+}
diff --git a/visualstudio-extension/DevGlobe/DevGlobe.csproj b/visualstudio-extension/DevGlobe/DevGlobe.csproj
new file mode 100644
index 0000000..d6fe7e8
--- /dev/null
+++ b/visualstudio-extension/DevGlobe/DevGlobe.csproj
@@ -0,0 +1,159 @@
+
+
+
+
+ 17.0
+ $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)
+
+
+
+ Debug
+ AnyCPU
+ {5F3A9C2E-7B14-4E8A-9D6F-1A2B3C4D5E6F}
+
+ {82b43b9b-a64c-4715-b499-d71e9ca2bd60};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}
+ Library
+ DevGlobe
+ DevGlobe
+ v4.7.2
+ true
+ true
+ true
+ false
+ false
+ true
+ true
+ Program
+ $(DevEnvDir)devenv.exe
+ /rootsuffix Exp
+ latest
+ annotations
+ true
+
+
+ true
+ full
+ false
+ bin\Debug\
+ DEBUG;TRACE
+ prompt
+ 4
+
+
+ pdbonly
+ true
+ bin\Release\
+ TRACE
+ prompt
+ 4
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ MSBuild:Compile
+ Designer
+
+
+ DevGlobeToolWindowControl.xaml
+
+
+ MSBuild:Compile
+ Designer
+
+
+ LoginView.xaml
+
+
+ MSBuild:Compile
+ Designer
+
+
+ DashboardView.xaml
+
+
+
+
+ Designer
+
+
+
+
+ Menus.ctmenu
+
+
+
+ true
+ VSPackage
+
+
+
+
+ true
+
+
+
+
+ 17.0.32112.339
+ runtime
+
+
+ 17.0.5232
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ 13.0.3
+
+
+
+
+
\ No newline at end of file
diff --git a/visualstudio-extension/DevGlobe/DevGlobeConfig.cs b/visualstudio-extension/DevGlobe/DevGlobeConfig.cs
new file mode 100644
index 0000000..fbf735a
--- /dev/null
+++ b/visualstudio-extension/DevGlobe/DevGlobeConfig.cs
@@ -0,0 +1,445 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Runtime.InteropServices;
+using System.Security.AccessControl;
+using System.Security.Principal;
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace DevGlobe
+{
+ ///
+ /// Manages DevGlobe configuration: the ~/.devglobe paths, reading/writing config.toml
+ /// (api_key, debug, [privacy]) in a simple TOML format, and storing the secret in the
+ /// Windows Credential Manager.
+ ///
+ /// Parsing rule: api_key and debug are read/written only until a [section] header is
+ /// reached (`beforeSection`).
+ ///
+ public static class DevGlobeConfig
+ {
+ private const string TargetName = "DevGlobe:api_key";
+
+ // ---- Paths ---------------------------------------------------------
+
+ /// %USERPROFILE%\.devglobe (HOME sur POSIX).
+ public static string DevGlobeDir =>
+ Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
+ ".devglobe");
+
+ /// %USERPROFILE%\.devglobe\config.toml
+ public static string ConfigPath => Path.Combine(DevGlobeDir, "config.toml");
+
+ /// %USERPROFILE%\.devglobe\devglobe.log
+ public static string LogPath => Path.Combine(DevGlobeDir, "devglobe.log");
+
+ // ---- api_key -------------------------------------------------------
+
+ private static readonly Regex ApiKeyLine =
+ new Regex("^api_key\\s*=\\s*\"([^\"]*)\"", RegexOptions.Compiled);
+
+ ///
+ /// Reads api_key from the section-less area of config.toml. Returns null if absent/empty.
+ ///
+ public static string? ReadApiKey()
+ {
+ if (!File.Exists(ConfigPath)) return null;
+
+ var beforeSection = true;
+ foreach (var rawLine in File.ReadAllLines(ConfigPath))
+ {
+ var line = rawLine.Trim();
+ if (line.StartsWith("[")) beforeSection = false;
+ if (!beforeSection) continue;
+
+ var m = ApiKeyLine.Match(line);
+ if (m.Success)
+ {
+ var key = m.Groups[1].Value;
+ Log.Info("config api_key read", new { exists = true, length = key.Length });
+ return string.IsNullOrEmpty(key) ? null : key;
+ }
+ }
+
+ Log.Info("config api_key read", new { exists = false });
+ return null;
+ }
+
+ ///
+ /// Writes/replaces the section-less api_key, preserving the rest of the file.
+ ///
+ public static void WriteApiKey(string key)
+ {
+ EnsureDir();
+
+ var lines = ReadExistingLines();
+ var updated = new List();
+ var inserted = false;
+ var beforeSection = true;
+
+ foreach (var rawLine in lines)
+ {
+ var line = rawLine.Trim();
+ if (line.StartsWith("[")) beforeSection = false;
+
+ if (beforeSection && line.StartsWith("api_key"))
+ {
+ updated.Add($"api_key = \"{key}\"");
+ inserted = true;
+ }
+ else
+ {
+ updated.Add(rawLine);
+ }
+ }
+
+ if (!inserted) updated.Insert(0, $"api_key = \"{key}\"");
+
+ WriteAllLinesRestricted(updated);
+ Log.Info("config api_key written", new { length = key.Length });
+ }
+
+ /// Removes the section-less api_key line.
+ public static void ClearApiKey()
+ {
+ if (!File.Exists(ConfigPath)) return;
+
+ var updated = new List();
+ var beforeSection = true;
+ foreach (var rawLine in ReadExistingLines())
+ {
+ var line = rawLine.Trim();
+ if (line.StartsWith("[")) beforeSection = false;
+ if (beforeSection && line.StartsWith("api_key")) continue;
+ updated.Add(rawLine);
+ }
+
+ WriteAllLinesRestricted(updated);
+ Log.Info("config api_key cleared");
+ }
+
+ // ---- debug ---------------------------------------------------------
+
+ private static readonly Regex DebugLine =
+ new Regex("^debug\\s*=\\s*(true|false)", RegexOptions.Compiled);
+
+ /// True if debug = true in the section-less area.
+ public static bool IsDebugEnabled()
+ {
+ if (!File.Exists(ConfigPath)) return false;
+
+ var beforeSection = true;
+ foreach (var rawLine in File.ReadAllLines(ConfigPath))
+ {
+ var line = rawLine.Trim();
+ if (line.StartsWith("[")) beforeSection = false;
+ if (!beforeSection) continue;
+
+ var m = DebugLine.Match(line);
+ if (m.Success) return m.Groups[1].Value == "true";
+ }
+ return false;
+ }
+
+ ///
+ /// Enables/disables debug. When enabled, inserts "debug = true" (after api_key if present).
+ /// When disabled, removes any section-less debug line.
+ ///
+ public static void SetDebug(bool enabled)
+ {
+ EnsureDir();
+
+ var lines = ReadExistingLines();
+ var updated = new List();
+ var inserted = false;
+ var beforeSection = true;
+
+ foreach (var rawLine in lines)
+ {
+ var line = rawLine.Trim();
+ if (line.StartsWith("[")) beforeSection = false;
+
+ if (beforeSection && line.StartsWith("debug"))
+ {
+ if (enabled) updated.Add("debug = true");
+ // when disabled: omit the line (= default)
+ inserted = true;
+ }
+ else
+ {
+ updated.Add(rawLine);
+ }
+ }
+
+ if (!inserted && enabled)
+ {
+ var apiKeyIdx = updated.FindIndex(l => l.Trim().StartsWith("api_key"));
+ if (apiKeyIdx >= 0) updated.Insert(apiKeyIdx + 1, "debug = true");
+ else updated.Insert(0, "debug = true");
+ }
+
+ WriteAllLinesRestricted(updated);
+ }
+
+ // ---- Internal TOML I/O --------------------------------------------
+
+ private static void EnsureDir()
+ {
+ if (!Directory.Exists(DevGlobeDir)) Directory.CreateDirectory(DevGlobeDir);
+ }
+
+ private static IReadOnlyList ReadExistingLines()
+ {
+ if (!File.Exists(ConfigPath)) return Array.Empty();
+ // Split on '\n' (not ReadAllLines) to preserve the content faithfully.
+ var content = File.ReadAllText(ConfigPath);
+ return content.Split('\n');
+ }
+
+ ///
+ /// Writes the lines, collapsing triple newlines, guarantees a trailing '\n',
+ /// then restricts permissions to the owner.
+ ///
+ private static void WriteAllLinesRestricted(IReadOnlyList lines)
+ {
+ EnsureDir();
+
+ var output = string.Join("\n", lines);
+ output = Regex.Replace(output, "\\n{3,}", "\n\n");
+ if (!output.EndsWith("\n")) output += "\n";
+
+ File.WriteAllText(ConfigPath, output, new UTF8Encoding(false));
+ RestrictToOwner(ConfigPath);
+ }
+
+ ///
+ /// Windows equivalent of mode 0600: NTFS ACL without inheritance, granting full control
+ /// to the current owner only. On POSIX: chmod 600 via reflection, since
+ /// File.SetUnixFileMode does not exist on net472.
+ ///
+ private static void RestrictToOwner(string path)
+ {
+ try
+ {
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ {
+ var fileInfo = new FileInfo(path);
+ var security = fileInfo.GetAccessControl();
+ security.SetAccessRuleProtection(isProtected: true, preserveInheritance: false);
+
+ var owner = WindowsIdentity.GetCurrent().User;
+ if (owner != null)
+ {
+ // Purge existing rules, then add the owner only.
+ var rules = security.GetAccessRules(true, false, typeof(SecurityIdentifier));
+ foreach (FileSystemAccessRule r in rules)
+ security.RemoveAccessRule(r);
+
+ security.AddAccessRule(new FileSystemAccessRule(
+ owner,
+ FileSystemRights.FullControl,
+ AccessControlType.Allow));
+
+ fileInfo.SetAccessControl(security);
+ }
+ }
+ else
+ {
+ // POSIX: chmod 600 via reflection to stay compilable on net472.
+ var modeType = Type.GetType("System.IO.UnixFileMode");
+ if (modeType != null)
+ {
+ var setMode = typeof(File).GetMethod("SetUnixFileMode",
+ new Type[] { typeof(string), modeType });
+ if (setMode != null)
+ {
+ // 0600 = UserRead | UserWrite = 0x100 | 0x80 = 384.
+ var mode = Enum.ToObject(modeType, 384);
+ setMode.Invoke(null, new object[] { path, mode });
+ }
+ }
+ }
+ }
+ catch
+ {
+ // Permission restriction is best-effort: it must never block the write.
+ }
+ }
+
+ // ---- Windows Credential Manager (P/Invoke) ------------------------
+
+ private const int CRED_TYPE_GENERIC = 1;
+ private const int CRED_PERSIST_LOCAL_MACHINE = 2;
+
+ [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
+ private struct CREDENTIAL
+ {
+ public int Flags;
+ public int Type;
+ public IntPtr TargetName;
+ public IntPtr Comment;
+ public System.Runtime.InteropServices.ComTypes.FILETIME LastWritten;
+ public int CredentialBlobSize;
+ public IntPtr CredentialBlob;
+ public int Persist;
+ public int AttributeCount;
+ public IntPtr Attributes;
+ public IntPtr TargetAlias;
+ public IntPtr UserName;
+ }
+
+ [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode, EntryPoint = "CredWriteW")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool CredWrite([In] ref CREDENTIAL credential, [In] uint flags);
+
+ [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode, EntryPoint = "CredReadW")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool CredRead(string target, int type, int reservedFlag, out IntPtr credentialPtr);
+
+ [DllImport("advapi32.dll", SetLastError = true, CharSet = CharSet.Unicode, EntryPoint = "CredDeleteW")]
+ [return: MarshalAs(UnmanagedType.Bool)]
+ private static extern bool CredDelete(string target, int type, int flags);
+
+ [DllImport("advapi32.dll", EntryPoint = "CredFree")]
+ private static extern void CredFree([In] IntPtr buffer);
+
+ /// Stores (or replaces) the API key in the Windows Credential Manager.
+ public static void StoreSecret(string key)
+ {
+ var blob = Encoding.Unicode.GetBytes(key ?? string.Empty);
+ var blobPtr = Marshal.AllocHGlobal(blob.Length);
+ var targetPtr = Marshal.StringToHGlobalUni(TargetName);
+ try
+ {
+ Marshal.Copy(blob, 0, blobPtr, blob.Length);
+ var cred = new CREDENTIAL
+ {
+ Type = CRED_TYPE_GENERIC,
+ TargetName = targetPtr,
+ CredentialBlobSize = blob.Length,
+ CredentialBlob = blobPtr,
+ Persist = CRED_PERSIST_LOCAL_MACHINE,
+ AttributeCount = 0,
+ Attributes = IntPtr.Zero,
+ Comment = IntPtr.Zero,
+ TargetAlias = IntPtr.Zero,
+ UserName = IntPtr.Zero,
+ };
+
+ if (!CredWrite(ref cred, 0))
+ {
+ var err = Marshal.GetLastWin32Error();
+ Log.Warn("CredWrite failed", new { error = err });
+ }
+ }
+ finally
+ {
+ Marshal.FreeHGlobal(blobPtr);
+ Marshal.FreeHGlobal(targetPtr);
+ }
+ }
+
+ /// Reads the API key from the Credential Manager, or null if absent.
+ public static string? GetSecret()
+ {
+ if (!CredRead(TargetName, CRED_TYPE_GENERIC, 0, out var credPtr))
+ return null;
+
+ try
+ {
+ var cred = Marshal.PtrToStructure(credPtr);
+ if (cred.CredentialBlobSize == 0 || cred.CredentialBlob == IntPtr.Zero)
+ return null;
+
+ var bytes = new byte[cred.CredentialBlobSize];
+ Marshal.Copy(cred.CredentialBlob, bytes, 0, cred.CredentialBlobSize);
+ return Encoding.Unicode.GetString(bytes);
+ }
+ finally
+ {
+ CredFree(credPtr);
+ }
+ }
+
+ /// Removes the API key from the Credential Manager (no-op if absent).
+ public static void DeleteSecret()
+ {
+ if (!CredDelete(TargetName, CRED_TYPE_GENERIC, 0))
+ {
+ var err = Marshal.GetLastWin32Error();
+ // 1168 = ERROR_NOT_FOUND: already absent, non-fatal.
+ if (err != 1168) Log.Warn("CredDelete failed", new { error = err });
+ }
+ }
+
+ // ---- tracking_enabled ----------------------------------------------
+
+ ///
+ /// Reads tracking_enabled from config.toml (root section). Absent means true.
+ /// Same parsing as ReadApiKey/IsDebugEnabled (stops at the first section).
+ ///
+ public static bool IsTrackingEnabled()
+ {
+ if (!File.Exists(ConfigPath)) return true;
+
+ var beforeSection = true;
+ foreach (var rawLine in File.ReadAllLines(ConfigPath))
+ {
+ var line = rawLine.Trim();
+ if (line.StartsWith("[")) beforeSection = false;
+ if (!beforeSection) continue;
+
+ var m = Regex.Match(line, @"^tracking_enabled\s*=\s*(true|false)");
+ if (m.Success) return m.Groups[1].Value == "true";
+ }
+ return true;
+ }
+
+ ///
+ /// Writes/removes tracking_enabled in config.toml. When the value is the default (true)
+ /// the line is omitted; when false it is written explicitly. Inserted just after api_key
+ /// if present, otherwise at the top.
+ ///
+ public static void SetTrackingEnabled(bool enabled)
+ {
+ Directory.CreateDirectory(DevGlobeDir);
+
+ var content = File.Exists(ConfigPath) ? File.ReadAllText(ConfigPath) : string.Empty;
+ var lines = content.Split('\n');
+ var updated = new List();
+ var beforeSection = true;
+ var handled = false;
+
+ foreach (var rawLine in lines)
+ {
+ var line = rawLine.Trim();
+ if (line.StartsWith("[")) beforeSection = false;
+
+ if (beforeSection && line.StartsWith("tracking_enabled"))
+ {
+ // false => write the line; true (default) => omit it.
+ if (!enabled) updated.Add("tracking_enabled = false");
+ handled = true;
+ }
+ else
+ {
+ updated.Add(rawLine);
+ }
+ }
+
+ if (!handled && !enabled)
+ {
+ var apiKeyIdx = updated.FindIndex(l => l.Trim().StartsWith("api_key"));
+ if (apiKeyIdx >= 0) updated.Insert(apiKeyIdx + 1, "tracking_enabled = false");
+ else updated.Insert(0, "tracking_enabled = false");
+ }
+
+ var output = Regex.Replace(string.Join("\n", updated), @"\n{3,}", "\n\n");
+ if (!output.EndsWith("\n")) output += "\n";
+ File.WriteAllText(ConfigPath, output);
+ Log.Info("config tracking_enabled written", new { enabled });
+ }
+ }
+}
diff --git a/visualstudio-extension/DevGlobe/DevGlobeNotifications.cs b/visualstudio-extension/DevGlobe/DevGlobeNotifications.cs
new file mode 100644
index 0000000..fc58590
--- /dev/null
+++ b/visualstudio-extension/DevGlobe/DevGlobeNotifications.cs
@@ -0,0 +1,137 @@
+using System;
+using System.Windows.Threading;
+using Microsoft.VisualStudio;
+using Microsoft.VisualStudio.Imaging;
+using Microsoft.VisualStudio.Imaging.Interop;
+using Microsoft.VisualStudio.Shell;
+using Microsoft.VisualStudio.Shell.Interop;
+
+namespace DevGlobe
+{
+ ///
+ /// User notifications via the native VS info bar (IVsInfoBar). Info notifications auto-dismiss
+ /// after a few seconds; warnings and errors stay until closed manually. The invalid-key error
+ /// carries a "Get API key" action link.
+ ///
+ internal static class DevGlobeNotifications
+ {
+ private static IServiceProvider _serviceProvider;
+ private static IVsInfoBarUIFactory _factory;
+ private static IVsInfoBarHost _host;
+ private static readonly TimeSpan InfoAutoDismiss = TimeSpan.FromSeconds(5);
+
+ ///
+ /// Call during package initialization. The host is not resolved here because the main window
+ /// (and thus its info bar host) does not exist yet at package load; the factory and host are
+ /// resolved lazily on first display.
+ ///
+ public static void Initialize(IServiceProvider serviceProvider)
+ {
+ _serviceProvider = serviceProvider;
+ }
+
+ /// Resolves and caches the info bar factory and host. Must run on the UI thread.
+ private static bool EnsureHost()
+ {
+ ThreadHelper.ThrowIfNotOnUIThread();
+ if (_serviceProvider == null) return false;
+
+ if (_factory == null)
+ _factory = _serviceProvider.GetService(typeof(SVsInfoBarUIFactory)) as IVsInfoBarUIFactory;
+
+ if (_host == null)
+ {
+ var shell = _serviceProvider.GetService(typeof(SVsShell)) as IVsShell;
+ if (shell != null &&
+ ErrorHandler.Succeeded(shell.GetProperty(
+ (int)__VSSPROPID7.VSSPROPID_MainWindowInfoBarHost, out object hostObj)))
+ {
+ _host = hostObj as IVsInfoBarHost;
+ }
+ }
+
+ return _factory != null && _host != null;
+ }
+
+ public static void Info(string message)
+ => Show(message, KnownMonikers.StatusInformation, autoDismiss: true);
+
+ public static void Warning(string message)
+ => Show(message, KnownMonikers.StatusWarning, autoDismiss: false);
+
+ public static void Error(string message)
+ => Show(message, KnownMonikers.StatusError, autoDismiss: false);
+
+ /// Error with an action link (e.g. "Get API key").
+ public static void ErrorWithAction(string message, string actionText, Action onAction)
+ => Show(message, KnownMonikers.StatusError, autoDismiss: false, actionText, onAction);
+
+ private static void Show(string message, ImageMoniker icon, bool autoDismiss,
+ string actionText = null, Action onAction = null)
+ {
+ _ = ThreadHelper.JoinableTaskFactory.RunAsync(async () =>
+ {
+ await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync();
+ try
+ {
+ if (!EnsureHost())
+ {
+ Log.Info("notify (no info bar host)", new { message });
+ return;
+ }
+
+ InfoBarModel model = actionText != null
+ ? new InfoBarModel(
+ new[] { new InfoBarTextSpan(message) },
+ new[] { new InfoBarHyperlink(actionText) },
+ icon, isCloseButtonVisible: true)
+ : new InfoBarModel(message, icon, isCloseButtonVisible: true);
+
+ var ui = _factory.CreateInfoBar(model);
+ var sink = new InfoBarEvents(onAction);
+ ui.Advise(sink, out uint cookie);
+ sink.Attach(cookie);
+ _host.AddInfoBar(ui);
+
+ if (autoDismiss)
+ {
+ var timer = new DispatcherTimer { Interval = InfoAutoDismiss };
+ timer.Tick += (s, e) =>
+ {
+ timer.Stop();
+ try { ui.Close(); } catch { /* already closed */ }
+ };
+ timer.Start();
+ }
+ }
+ catch (Exception ex)
+ {
+ Log.Warn("DevGlobeNotifications: show failed", new { error = ex.Message });
+ }
+ });
+ }
+
+ /// Info bar event sink: runs the action, then unsubscribes on close.
+ private sealed class InfoBarEvents : IVsInfoBarUIEvents
+ {
+ private readonly Action _onAction;
+ private uint _cookie;
+
+ public InfoBarEvents(Action onAction) => _onAction = onAction;
+
+ public void Attach(uint cookie) => _cookie = cookie;
+
+ public void OnClosed(IVsInfoBarUIElement infoBarUIElement)
+ {
+ try { infoBarUIElement.Unadvise(_cookie); } catch { /* best-effort */ }
+ }
+
+ public void OnActionItemClicked(IVsInfoBarUIElement infoBarUIElement, IVsInfoBarActionItem actionItem)
+ {
+ try { _onAction?.Invoke(); }
+ catch (Exception ex) { Log.Warn("notify action failed", new { error = ex.Message }); }
+ try { infoBarUIElement.Close(); } catch { /* best-effort */ }
+ }
+ }
+ }
+}
diff --git a/visualstudio-extension/DevGlobe/DevGlobePackage.cs b/visualstudio-extension/DevGlobe/DevGlobePackage.cs
new file mode 100644
index 0000000..a301d3d
--- /dev/null
+++ b/visualstudio-extension/DevGlobe/DevGlobePackage.cs
@@ -0,0 +1,414 @@
+using System;
+using System.Runtime.InteropServices;
+using System.Threading;
+using System.Threading.Tasks;
+using DevGlobe.Commands;
+using DevGlobe.ToolWindow;
+using Microsoft.VisualStudio;
+using Microsoft.VisualStudio.Shell;
+using Microsoft.VisualStudio.Shell.Interop;
+using Microsoft.VisualStudio.Threading; // provides GetAwaiter for `await TaskScheduler.Default`
+using Task = System.Threading.Tasks.Task;
+
+namespace DevGlobe
+{
+ /// Fixed integration GUIDs shared by the manifest, the .vsct, and the VSSDK components.
+ public static class DevGlobeGuids
+ {
+ public const string PackageGuidString = "5f3a9c2e-7b14-4e8a-9d6f-1a2b3c4d5e6f";
+ public const string ToolWindowGuidString = "6a4b0d3f-8c25-4f9b-ae7a-2b3c4d5e6f70";
+ public const string CommandSetGuidString = "7b5c1e40-9d36-40ac-bf8b-3c4d5e6f7081";
+
+ public static readonly Guid CommandSet = new Guid(CommandSetGuidString);
+ }
+
+ ///
+ /// In-proc VSSDK entry point. Registers commands before any network work, then bootstraps the
+ /// core binary best-effort (never blocking UI registration). The tool window wires to the
+ /// CoreClient via , which survives VS recreating the window.
+ ///
+ [PackageRegistration(UseManagedResourcesOnly = true, AllowsBackgroundLoading = true)]
+ [ProvideAutoLoad(VSConstants.UICONTEXT.NoSolution_string, PackageAutoLoadFlags.BackgroundLoad)]
+ [ProvideAutoLoad(VSConstants.UICONTEXT.SolutionExists_string, PackageAutoLoadFlags.BackgroundLoad)]
+ [ProvideToolWindow(typeof(DevGlobeToolWindow))]
+ [ProvideMenuResource("Menus.ctmenu", 2)]
+ [Guid(DevGlobeGuids.PackageGuidString)]
+ public sealed class DevGlobePackage : AsyncPackage
+ {
+ private CoreClient _coreClient;
+ private ActivityTracker _activityTracker;
+ private DevGlobeStatusBar _statusBar;
+
+ private const string PluginVersion = "0.1.0"; // must match source.extension.vsixmanifest
+
+ ///
+ /// Returns this package as the async factory for the tool window GUID. Required for
+ /// ShowToolWindowAsync(create:true) on an AsyncPackage; without it creation fails and
+ /// ShowToolWindowAsync returns null.
+ ///
+ public override IVsAsyncToolWindowFactory GetAsyncToolWindowFactory(Guid toolWindowType)
+ => toolWindowType.Equals(new Guid(DevGlobeToolWindow.WindowGuidString)) ? this : null;
+
+ /// Title shown while the tool window is created asynchronously.
+ protected override string GetToolWindowTitle(Type toolWindowType, int id)
+ => toolWindowType == typeof(DevGlobeToolWindow)
+ ? "DevGlobe"
+ : base.GetToolWindowTitle(toolWindowType, id);
+
+ ///
+ /// Async initialization. Commands (network-independent) are registered before the
+ /// best-effort core bootstrap. Wrapped in try/catch so a failure does not surface as an
+ /// opaque "SetSite failed" that blacklists the package.
+ ///
+ protected override async Task InitializeAsync(
+ CancellationToken cancellationToken,
+ IProgress progress)
+ {
+ await base.InitializeAsync(cancellationToken, progress);
+
+ try
+ {
+ await JoinableTaskFactory.SwitchToMainThreadAsync(cancellationToken);
+ Log.RefreshLevel();
+ Log.Info("DevGlobe activating…");
+
+ // User notifications via the VS info bar.
+ DevGlobeNotifications.Initialize(this);
+
+ // Wire shell callbacks first: VS may create or restore the tool window at any time
+ // and it reads from the shell.
+ DevGlobeShell.Instance.OnConnect = OnConnectAsync;
+ DevGlobeShell.Instance.OnDisconnect = OnDisconnectAsync;
+ DevGlobeShell.Instance.OnStart = OnStartAsync;
+ DevGlobeShell.Instance.OnStop = OnStopAsync;
+
+ // Register commands independently of the network.
+ await DevGlobeCommands.InitializeAllAsync(this, () => _coreClient);
+
+ // Resolve and migrate the API key.
+ var apiKey = ResolveAndMigrateApiKey();
+
+ var statusbarSvc = await GetServiceAsync(typeof(SVsStatusbar)) as IVsStatusbar;
+ _statusBar = statusbarSvc != null ? new DevGlobeStatusBar(statusbarSvc) : null;
+
+ // Bootstrap the core binary best-effort, on a background task. An auto-load package
+ // must never await a network operation (the core is ~114 MB) on its load path, or
+ // the IDE appears frozen during the download on first launch. Commands and the shell
+ // are already in place; StartCoreAsync wires the core once the binary is ready.
+ // DisposalToken keeps the task alive past the end of InitializeAsync.
+ _ = JoinableTaskFactory.RunAsync(async () =>
+ {
+ // Leave the init path immediately. Since the work below can complete synchronously
+ // (cached binary, already on the UI thread), RunAsync would otherwise execute this
+ // delegate inline during InitializeAsync and delay package initialization.
+ await TaskScheduler.Default;
+
+ string corePath = null;
+ try
+ {
+ corePath = await CoreBootstrap.EnsureBinaryAsync(DisposalToken);
+ Log.Info("DevGlobe core binary ready", new { corePath });
+ }
+ catch (Exception ex)
+ {
+ Log.Error("DevGlobe: core binary unavailable (degraded mode)", new { error = ex.Message });
+ }
+
+ await JoinableTaskFactory.SwitchToMainThreadAsync();
+ if (corePath != null)
+ {
+ await StartCoreAsync(corePath, apiKey, DisposalToken);
+ }
+ else
+ {
+ // Degraded mode: no client, but commands and shell.OnConnect are in place.
+ DevGlobeShell.Instance.RaiseStateChanged(new TrackerState());
+ }
+ });
+
+ // Show the DevGlobe toolbar once on first launch (custom toolbars are hidden by
+ // default in VS); afterwards VS remembers the user's choice. Deferred so the init
+ // path does not touch the shell (DTE/CommandBars).
+ _ = JoinableTaskFactory.RunAsync(async () =>
+ {
+ await TaskScheduler.Default;
+ await JoinableTaskFactory.SwitchToMainThreadAsync();
+ EnsureToolbarShownOnce();
+ });
+
+ // The panel is not opened automatically at startup; the user opens it via the
+ // DevGlobe toolbar button or View > Other Windows > DevGlobe. VS restores the tool
+ // window if it was docked in the previous session.
+ Log.Info("DevGlobe activated.");
+ }
+ catch (Exception ex)
+ {
+ Log.Error("DevGlobe: InitializeAsync failed", new { error = ex.ToString() });
+ }
+ }
+
+ /// Creates the CoreClient and ActivityTracker, then starts tracking per config.
+ private async Task StartCoreAsync(string corePath, string apiKey, CancellationToken ct)
+ {
+ _coreClient = new CoreClient(
+ corePath,
+ state => UpdateToolWindowState(state),
+ PluginVersion,
+ OnInvalidApiKey,
+ statusBar: null, // status bar is handled at the package level (UpdateToolWindowState).
+ notifyInfo: msg => DevGlobeNotifications.Info("DevGlobe: " + msg),
+ notifyError: msg => DevGlobeNotifications.Error("DevGlobe: " + msg),
+ offerReconnect: OfferReconnect);
+ DevGlobeShell.Instance.Client = _coreClient;
+
+ _activityTracker = new ActivityTracker(this, _coreClient);
+ await _activityTracker.InitializeAsync(ct);
+ Log.Info("DevGlobe: ActivityTracker initialized");
+
+ var trackingEnabled = DevGlobeConfig.IsTrackingEnabled();
+ if (!string.IsNullOrEmpty(apiKey) && trackingEnabled)
+ {
+ _coreClient.Init();
+ _coreClient.Start();
+ }
+ else if (!string.IsNullOrEmpty(apiKey))
+ {
+ _coreClient.Init();
+ UpdateToolWindowState(_coreClient.GetState());
+ }
+ else
+ {
+ UpdateToolWindowState(_coreClient.GetState());
+ }
+ }
+
+ private string ResolveAndMigrateApiKey()
+ {
+ var configKey = DevGlobeConfig.ReadApiKey();
+ if (!string.IsNullOrEmpty(configKey))
+ {
+ Log.Info("desktop api key resolved from config.toml", new { length = configKey.Length });
+ DevGlobeConfig.StoreSecret(configKey);
+ return configKey;
+ }
+
+ var stored = DevGlobeConfig.GetSecret();
+ if (!string.IsNullOrEmpty(stored))
+ {
+ Log.Info("desktop api key resolved from secret store", new { length = stored.Length });
+ DevGlobeConfig.WriteApiKey(stored);
+ return stored;
+ }
+
+ Log.Info("desktop api key resolved: none");
+ return string.Empty;
+ }
+
+ private void OnInvalidApiKey()
+ {
+ DevGlobeConfig.DeleteSecret();
+ DevGlobeConfig.ClearApiKey();
+ Log.Info("API key cleared after server rejected it (401)");
+ }
+
+ ///
+ /// Updates the status bar and broadcasts state via the shell (the tool window subscribes).
+ /// May be called from CoreClient.onStateChange on a non-UI thread, so work is marshalled
+ /// onto the UI thread.
+ ///
+ private void UpdateToolWindowState(TrackerState state)
+ {
+ _ = JoinableTaskFactory.RunAsync(async () =>
+ {
+ await JoinableTaskFactory.SwitchToMainThreadAsync();
+
+ if (_statusBar != null)
+ {
+ if (state.Tracking) _statusBar.UpdateTime(state.TodaySeconds);
+ else _statusBar.Hide();
+ }
+
+ DevGlobeShell.Instance.RaiseStateChanged(state);
+ });
+ }
+
+ ///
+ /// Connect from the Login view. Writes the key to both stores, then starts the core
+ /// (bootstrapping the binary on demand if running in degraded mode).
+ ///
+ private async Task OnConnectAsync(string rawKey)
+ {
+ await JoinableTaskFactory.SwitchToMainThreadAsync();
+
+ var token = (rawKey ?? string.Empty).Trim();
+ if (string.IsNullOrEmpty(token))
+ {
+ Log.Warn("desktop token empty, ignored");
+ DevGlobeNotifications.Error("DevGlobe: API key is empty.");
+ return;
+ }
+
+ Log.Info("desktop token saved from tool window", new { length = token.Length });
+ DevGlobeConfig.StoreSecret(token);
+ DevGlobeConfig.WriteApiKey(token);
+ DevGlobeConfig.SetTrackingEnabled(true);
+
+ if (_coreClient == null)
+ {
+ try
+ {
+ var corePath = await CoreBootstrap.EnsureBinaryAsync(DisposalToken);
+ await StartCoreAsync(corePath, token, DisposalToken);
+ DevGlobeNotifications.Info("DevGlobe: Connected!");
+ return; // StartCoreAsync already performed Init/Start.
+ }
+ catch (Exception ex)
+ {
+ Log.Error("DevGlobe: failed to start core on connect", new { error = ex.Message });
+ DevGlobeNotifications.Error("DevGlobe: failed to start tracking.");
+ return;
+ }
+ }
+
+ _coreClient.Init();
+ _coreClient.Start();
+ UpdateToolWindowState(_coreClient.GetState());
+ DevGlobeNotifications.Info("DevGlobe: Connected!");
+ }
+
+ /// Disconnect: clears the key from both stores and resets the core.
+ private async Task OnDisconnectAsync()
+ {
+ await JoinableTaskFactory.SwitchToMainThreadAsync();
+
+ DevGlobeConfig.DeleteSecret();
+ DevGlobeConfig.ClearApiKey();
+ _coreClient?.Reset();
+ UpdateToolWindowState(new TrackerState());
+ Log.Info("DevGlobe disconnected");
+ DevGlobeNotifications.Info("DevGlobe: Disconnected.");
+ }
+
+ ///
+ /// Start tracking from the Dashboard: requires a key, persists trackingEnabled=true,
+ /// (re)inits and starts, then notifies.
+ ///
+ private async Task OnStartAsync()
+ {
+ await JoinableTaskFactory.SwitchToMainThreadAsync();
+
+ var apiKey = DevGlobeConfig.ReadApiKey();
+ if (string.IsNullOrEmpty(apiKey))
+ {
+ // No key: do nothing (and no notification).
+ Log.Info("start tracking skipped: no api key");
+ return;
+ }
+
+ DevGlobeConfig.SetTrackingEnabled(true);
+
+ if (_coreClient == null)
+ {
+ try
+ {
+ var corePath = await CoreBootstrap.EnsureBinaryAsync(DisposalToken);
+ await StartCoreAsync(corePath, apiKey, DisposalToken);
+ }
+ catch (Exception ex)
+ {
+ Log.Error("DevGlobe: failed to start core on startTracking", new { error = ex.Message });
+ DevGlobeNotifications.Error("DevGlobe: failed to start tracking.");
+ return;
+ }
+ }
+ else
+ {
+ _coreClient.Init();
+ _coreClient.Start();
+ }
+
+ DevGlobeNotifications.Info("DevGlobe: Tracking started.");
+ }
+
+ ///
+ /// Stop tracking from the Dashboard: persists trackingEnabled=false, pauses, then notifies.
+ ///
+ private async Task OnStopAsync()
+ {
+ await JoinableTaskFactory.SwitchToMainThreadAsync();
+
+ DevGlobeConfig.SetTrackingEnabled(false);
+ _coreClient?.Pause();
+ UpdateToolWindowState(_coreClient?.GetState() ?? new TrackerState());
+ Log.Info("DevGlobe tracking stopped");
+ DevGlobeNotifications.Info("DevGlobe: Tracking stopped.");
+ }
+
+ ///
+ /// API key rejected (401): shows an error with a "Get API key" action that opens the
+ /// dashboard settings page.
+ ///
+ private void OfferReconnect()
+ {
+ DevGlobeNotifications.ErrorWithAction(
+ "DevGlobe: invalid API key. Please reconnect with a valid key.",
+ "Get API key",
+ () =>
+ {
+ try
+ {
+ System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo(
+ "https://devglobe.app/dashboard/settings") { UseShellExecute = true });
+ }
+ catch (Exception ex)
+ {
+ Log.Warn("OfferReconnect: open browser failed", new { error = ex.Message });
+ }
+ });
+ }
+
+ ///
+ /// Shows the DevGlobe toolbar on the first launch only (tracked by a marker file in
+ /// ~/.devglobe). Custom toolbars are hidden by default in VS; this surfaces the panel once,
+ /// then respects the user's later choice.
+ ///
+ private void EnsureToolbarShownOnce()
+ {
+ ThreadHelper.ThrowIfNotOnUIThread();
+ try
+ {
+ var marker = System.IO.Path.Combine(DevGlobeConfig.DevGlobeDir, ".vs_toolbar_initialized");
+ if (System.IO.File.Exists(marker)) return;
+
+ if (GetService(typeof(SDTE)) is EnvDTE.DTE dte)
+ {
+ dynamic commandBars = dte.CommandBars;
+ dynamic bar = commandBars["DevGlobe"];
+ if (bar != null)
+ {
+ bar.Visible = true;
+ Log.Info("DevGlobe toolbar shown (first run)");
+ }
+ }
+
+ System.IO.Directory.CreateDirectory(DevGlobeConfig.DevGlobeDir);
+ System.IO.File.WriteAllText(marker, "1");
+ }
+ catch (Exception ex)
+ {
+ Log.Warn("DevGlobe: could not show toolbar on first run", new { error = ex.Message });
+ }
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _activityTracker?.Dispose();
+ _coreClient?.Dispose();
+ _statusBar?.Hide();
+ }
+ base.Dispose(disposing);
+ }
+ }
+}
diff --git a/visualstudio-extension/DevGlobe/DevGlobeShell.cs b/visualstudio-extension/DevGlobe/DevGlobeShell.cs
new file mode 100644
index 0000000..de1fb9d
--- /dev/null
+++ b/visualstudio-extension/DevGlobe/DevGlobeShell.cs
@@ -0,0 +1,42 @@
+using System;
+using System.Threading.Tasks;
+
+namespace DevGlobe
+{
+ ///
+ /// Shared package state accessible to the tool window regardless of its lifecycle. VS may
+ /// recreate the tool window via its parameterless constructor (layout restore, reopen) without
+ /// going through the package, so this singleton holds the callbacks and survives those
+ /// recreations: the package fills it in InitializeAsync, and the tool window consumes it in its
+ /// constructor and subscribes to .
+ ///
+ public sealed class DevGlobeShell
+ {
+ public static DevGlobeShell Instance { get; } = new DevGlobeShell();
+
+ private DevGlobeShell() { }
+
+ /// Current daemon client (recreated after reset/reconnect, null in degraded mode).
+ public CoreClient Client { get; set; }
+
+ /// Connect from the Login view: writes config and (re)inits and starts.
+ public Func OnConnect { get; set; }
+
+ /// Disconnect from the Dashboard: clears the key and resets the core.
+ public Func OnDisconnect { get; set; }
+
+ /// Start tracking from the Dashboard: persists tracking_enabled and (re)inits and starts.
+ public Func OnStart { get; set; }
+
+ /// Stop tracking from the Dashboard: persists tracking_enabled=false and pauses.
+ public Func OnStop { get; set; }
+
+ /// Raised on every state change; the tool window subscribes to refresh itself.
+ public event Action StateChanged;
+
+ public void RaiseStateChanged(TrackerState state) => StateChanged?.Invoke(state);
+
+ /// Current state from the client, or null if there is no client.
+ public TrackerState CurrentState => Client?.GetState();
+ }
+}
diff --git a/visualstudio-extension/DevGlobe/EditorInfo.cs b/visualstudio-extension/DevGlobe/EditorInfo.cs
new file mode 100644
index 0000000..4ec65a3
--- /dev/null
+++ b/visualstudio-extension/DevGlobe/EditorInfo.cs
@@ -0,0 +1,15 @@
+namespace DevGlobe
+{
+ ///
+ /// Editor identity sent to the core (the `editor` field of the `init` message) and used
+ /// as a tag in logs.
+ ///
+ public static class EditorInfo
+ {
+ /// Identifier recognized by devglobe.app (icon/name on the globe).
+ public const string EditorId = "visualstudio";
+
+ /// Always returns "visualstudio".
+ public static string DetectEditor() => EditorId;
+ }
+}
diff --git a/visualstudio-extension/DevGlobe/LanguageMap.cs b/visualstudio-extension/DevGlobe/LanguageMap.cs
new file mode 100644
index 0000000..ac4eccf
--- /dev/null
+++ b/visualstudio-extension/DevGlobe/LanguageMap.cs
@@ -0,0 +1,191 @@
+using System;
+using System.Collections.Generic;
+
+namespace DevGlobe
+{
+ ///
+ /// Maps a language identifier to the canonical display name shown on the globe.
+ /// VS does not expose a languageId, so accepts a VS content-type
+ /// (e.g. "CSharp", "C/C++") OR a file extension (e.g. ".cs", "tsx") and returns the
+ /// canonical name (e.g. "C#", "React TSX"), falling back to capitalizing the first
+ /// letter for unknown identifiers.
+ ///
+ public static class LanguageMap
+ {
+ ///
+ /// Canonical table shared with the other DevGlobe extensions.
+ /// Key = language identifier, value = name displayed on the globe. Case-insensitive lookup.
+ ///
+ private static readonly Dictionary LANG_MAP =
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ { "javascript", "JavaScript" }, { "typescript", "TypeScript" },
+ { "javascriptreact", "React JSX" }, { "typescriptreact", "React TSX" },
+ { "vue", "Vue" }, { "svelte", "Svelte" }, { "astro", "Astro" }, { "angular", "Angular" },
+ { "html", "HTML" }, { "css", "CSS" }, { "sass", "Sass" }, { "scss", "SCSS" },
+ { "less", "Less" }, { "stylus", "Stylus" },
+ { "graphql", "GraphQL" }, { "mdx", "MDX" },
+ { "handlebars", "Handlebars" }, { "pug", "Pug" }, { "jade", "Pug" }, { "ejs", "EJS" },
+ { "erb", "ERB" }, { "haml", "Haml" }, { "twig", "Twig" }, { "blade", "Blade" },
+ { "django-html", "Django" }, { "jinja", "Jinja" }, { "liquid", "Liquid" },
+ { "mustache", "Mustache" }, { "razor", "Razor" }, { "nunjucks", "Nunjucks" },
+ { "c", "C" }, { "cpp", "C++" }, { "rust", "Rust" }, { "go", "Go" }, { "zig", "Zig" },
+ { "d", "D" }, { "v", "V" }, { "odin", "Odin" }, { "carbon", "Carbon" }, { "mojo", "Mojo" },
+ { "java", "Java" }, { "kotlin", "Kotlin" }, { "scala", "Scala" }, { "groovy", "Groovy" },
+ { "csharp", "C#" }, { "fsharp", "F#" }, { "vb", "Visual Basic" },
+ { "python", "Python" }, { "ruby", "Ruby" }, { "php", "PHP" }, { "lua", "Lua" },
+ { "perl", "Perl" }, { "r", "R" }, { "julia", "Julia" }, { "matlab", "MATLAB" },
+ { "swift", "Swift" }, { "dart", "Dart" }, { "objective-c", "Objective-C" },
+ { "objective-cpp", "Objective-C++" },
+ { "haskell", "Haskell" }, { "elixir", "Elixir" }, { "erlang", "Erlang" },
+ { "ocaml", "OCaml" }, { "elm", "Elm" }, { "purescript", "PureScript" },
+ { "clojure", "Clojure" }, { "racket", "Racket" }, { "scheme", "Scheme" },
+ { "commonlisp", "Common Lisp" }, { "prolog", "Prolog" },
+ { "gleam", "Gleam" }, { "roc", "Roc" }, { "idris", "Idris" }, { "agda", "Agda" },
+ { "lean", "Lean" }, { "coq", "Coq" },
+ { "nim", "Nim" }, { "crystal", "Crystal" }, { "haxe", "Haxe" },
+ { "ada", "Ada" }, { "fortran", "Fortran" }, { "pascal", "Pascal" }, { "cobol", "COBOL" },
+ { "vhdl", "VHDL" }, { "verilog", "Verilog" }, { "systemverilog", "SystemVerilog" },
+ { "asm", "Assembly" }, { "arm64", "ARM64" }, { "cuda", "CUDA" },
+ { "glsl", "GLSL" }, { "hlsl", "HLSL" }, { "wgsl", "WGSL" }, { "metal", "Metal" },
+ { "shaderlab", "ShaderLab" },
+ { "shellscript", "Bash" }, { "powershell", "PowerShell" }, { "fish", "Fish" },
+ { "bat", "Batch" },
+ { "terraform", "Terraform" }, { "bicep", "Bicep" }, { "pulumi", "Pulumi" },
+ { "nix", "Nix" }, { "ansible", "Ansible" }, { "puppet", "Puppet" },
+ { "dockerfile", "Docker" }, { "docker-compose", "Docker Compose" },
+ { "makefile", "Makefile" }, { "cmake", "CMake" }, { "just", "Just" }, { "meson", "Meson" },
+ { "sql", "SQL" }, { "plsql", "PL/SQL" }, { "mysql", "MySQL" }, { "pgsql", "PostgreSQL" },
+ { "mongodb", "MongoDB" }, { "redis", "Redis" }, { "cypher", "Cypher" },
+ { "sparql", "SPARQL" }, { "prisma", "Prisma" },
+ { "solidity", "Solidity" }, { "vyper", "Vyper" }, { "move", "Move" }, { "cairo", "Cairo" },
+ { "gdscript", "GDScript" }, { "gdresource", "Godot Resource" },
+ { "gdshader", "Godot Shader" },
+ { "json", "JSON" }, { "jsonc", "JSON" }, { "jsonnet", "Jsonnet" },
+ { "yaml", "YAML" }, { "toml", "TOML" }, { "xml", "XML" }, { "ini", "INI" },
+ { "dotenv", "Config" }, { "properties", "Config" },
+ { "csv", "CSV" }, { "tsv", "TSV" },
+ { "cue", "CUE" }, { "dhall", "Dhall" }, { "pkl", "Pkl" },
+ { "proto", "Protobuf" }, { "protobuf", "Protobuf" }, { "thrift", "Thrift" },
+ { "avro", "Avro" },
+ { "markdown", "Markdown" }, { "restructuredtext", "reStructuredText" },
+ { "latex", "LaTeX" }, { "tex", "LaTeX" }, { "bibtex", "BibTeX" }, { "typst", "Typst" },
+ { "asciidoc", "AsciiDoc" }, { "plaintext", "Plain Text" },
+ { "coffeescript", "CoffeeScript" }, { "tcl", "Tcl" }, { "awk", "AWK" }, { "sed", "Sed" },
+ { "regex", "Regex" }, { "diff", "Diff" }, { "git-commit", "Git Commit" },
+ { "git-rebase", "Git Rebase" },
+ { "ignore", "Gitignore" }, { "editorconfig", "EditorConfig" },
+ { "http", "HTTP" }, { "ssh_config", "SSH Config" },
+ { "log", "Log" },
+ };
+
+ ///
+ /// Visual Studio content-type (IContentType.TypeName) -> LANG_MAP key.
+ /// Only covers content-types whose name differs from a canonical key. Any content-type
+ /// that already matches a LANG_MAP key (e.g. "JavaScript", "TypeScript", "F#") is
+ /// resolved by the direct lookup and needs no alias.
+ ///
+ private static readonly Dictionary CONTENT_TYPE_ALIASES =
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ { "csharp", "csharp" },
+ { "c/c++", "cpp" },
+ { "basic", "vb" }, // "Basic" -> Visual Basic
+ { "htmlx", "html" }, // VS HTML editor
+ { "razor", "razor" },
+ { "css", "css" },
+ { "less", "less" },
+ { "scss", "scss" },
+ { "json", "json" },
+ { "jade", "pug" },
+ { "xaml", "xml" }, // XAML rendered as XML on the globe
+ { "code++.fortran", "fortran" },
+ { "plain text", "plaintext" },
+ };
+
+ ///
+ /// File extension (no leading dot, lowercase) -> LANG_MAP key.
+ /// Used when the VS content-type is absent or generic ("plaintext").
+ ///
+ private static readonly Dictionary EXTENSION_ALIASES =
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ { "cs", "csharp" }, { "csx", "csharp" },
+ { "vb", "vb" },
+ { "fs", "fsharp" }, { "fsx", "fsharp" }, { "fsi", "fsharp" },
+ { "ts", "typescript" }, { "mts", "typescript" }, { "cts", "typescript" },
+ { "tsx", "typescriptreact" },
+ { "js", "javascript" }, { "mjs", "javascript" }, { "cjs", "javascript" },
+ { "jsx", "javascriptreact" },
+ { "vue", "vue" }, { "svelte", "svelte" }, { "astro", "astro" },
+ { "razor", "razor" }, { "cshtml", "razor" }, { "vbhtml", "razor" },
+ { "html", "html" }, { "htm", "html" },
+ { "css", "css" }, { "scss", "scss" }, { "sass", "sass" }, { "less", "less" },
+ { "c", "c" }, { "h", "c" },
+ { "cpp", "cpp" }, { "cc", "cpp" }, { "cxx", "cpp" }, { "hpp", "cpp" },
+ { "hh", "cpp" }, { "hxx", "cpp" }, { "ino", "cpp" },
+ { "rs", "rust" }, { "go", "go" }, { "zig", "zig" },
+ { "java", "java" }, { "kt", "kotlin" }, { "kts", "kotlin" },
+ { "scala", "scala" }, { "groovy", "groovy" },
+ { "py", "python" }, { "pyw", "python" }, { "pyi", "python" },
+ { "rb", "ruby" }, { "php", "php" }, { "lua", "lua" }, { "pl", "perl" }, { "pm", "perl" },
+ { "r", "r" }, { "jl", "julia" }, { "m", "matlab" },
+ { "swift", "swift" }, { "dart", "dart" },
+ { "hs", "haskell" }, { "ex", "elixir" }, { "exs", "elixir" },
+ { "erl", "erlang" }, { "ml", "ocaml" }, { "elm", "elm" }, { "clj", "clojure" },
+ { "nim", "nim" }, { "cr", "crystal" }, { "hx", "haxe" },
+ { "json", "json" }, { "jsonc", "jsonc" },
+ { "yaml", "yaml" }, { "yml", "yaml" }, { "toml", "toml" },
+ { "xml", "xml" }, { "xaml", "xml" }, { "ini", "ini" },
+ { "sql", "sql" }, { "graphql", "graphql" }, { "gql", "graphql" },
+ { "sh", "shellscript" }, { "bash", "shellscript" }, { "zsh", "shellscript" },
+ { "ps1", "powershell" }, { "psm1", "powershell" },
+ { "bat", "bat" }, { "cmd", "bat" },
+ { "tf", "terraform" }, { "bicep", "bicep" },
+ { "dockerfile", "dockerfile" },
+ { "md", "markdown" }, { "markdown", "markdown" }, { "mdx", "mdx" },
+ { "tex", "latex" }, { "proto", "proto" },
+ { "txt", "plaintext" }, { "log", "log" },
+ };
+
+ ///
+ /// Maps a Visual Studio content-type or file extension to the canonical name.
+ /// Returns string.Empty for a null/empty input; for an unknown input, returns the
+ /// input with its first letter capitalized.
+ ///
+ public static string Map(string contentTypeOrExtension)
+ {
+ if (string.IsNullOrWhiteSpace(contentTypeOrExtension))
+ {
+ return string.Empty;
+ }
+
+ // Normalize: trim and strip a leading dot (extension ".cs" -> "cs").
+ string raw = contentTypeOrExtension.Trim();
+ string key = raw.StartsWith(".", StringComparison.Ordinal) ? raw.Substring(1) : raw;
+
+ // VS content-type alias -> canonical key.
+ if (CONTENT_TYPE_ALIASES.TryGetValue(key, out string viaContentType) &&
+ LANG_MAP.TryGetValue(viaContentType, out string nameFromContentType))
+ {
+ return nameFromContentType;
+ }
+
+ // File extension alias -> canonical key.
+ if (EXTENSION_ALIASES.TryGetValue(key, out string viaExtension) &&
+ LANG_MAP.TryGetValue(viaExtension, out string nameFromExtension))
+ {
+ return nameFromExtension;
+ }
+
+ // Direct lookup (the input is already a canonical key, e.g. "javascript").
+ if (LANG_MAP.TryGetValue(key, out string direct))
+ {
+ return direct;
+ }
+
+ // Fallback: capitalize the first letter, leave the rest unchanged.
+ return char.ToUpperInvariant(raw[0]) + raw.Substring(1);
+ }
+ }
+}
diff --git a/visualstudio-extension/DevGlobe/Logger.cs b/visualstudio-extension/DevGlobe/Logger.cs
new file mode 100644
index 0000000..817afef
--- /dev/null
+++ b/visualstudio-extension/DevGlobe/Logger.cs
@@ -0,0 +1,105 @@
+using System;
+using System.IO;
+using System.Text;
+using Newtonsoft.Json;
+
+namespace DevGlobe
+{
+ ///
+ /// File logger for the extension. Writes to DevGlobeConfig.LogPath, one line per call,
+ /// in the format "ISO LEVEL [visualstudio] message {json-data}", with append and rotation.
+ ///
+ /// Levels: ERROR always; INFO/WARN only when debug is enabled (config.toml debug = true).
+ /// The logger must NEVER throw, otherwise it would break the IDE.
+ ///
+ public static class Log
+ {
+ private const long MaxLogBytes = 5 * 1024 * 1024; // rotate beyond 5 MB
+ private const int TruncateKeepBytes = 1 * 1024 * 1024; // keep the last 1 MB
+
+ private static readonly object Gate = new object();
+ private static bool _debug;
+
+ ///
+ /// Re-evaluates the log level from config (debug = true enables INFO/WARN).
+ ///
+ public static void RefreshLevel()
+ {
+ try { _debug = DevGlobeConfig.IsDebugEnabled(); }
+ catch { _debug = false; }
+ }
+
+ public static void Info(string msg, object? data = null)
+ {
+ if (_debug) Write("INFO", msg, data);
+ }
+
+ public static void Warn(string msg, object? data = null)
+ {
+ if (_debug) Write("WARN", msg, data);
+ }
+
+ public static void Error(string msg, object? data = null)
+ {
+ Write("ERROR", msg, data);
+ }
+
+ private static void Write(string level, string msg, object? data)
+ {
+ try
+ {
+ var timestamp = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss.fffZ");
+ var payload = FormatData(data);
+ var line = $"{timestamp} {level} [{EditorInfo.EditorId}] {msg}{payload}\n";
+
+ var path = DevGlobeConfig.LogPath;
+ var dir = Path.GetDirectoryName(path);
+ if (!string.IsNullOrEmpty(dir) && !Directory.Exists(dir))
+ Directory.CreateDirectory(dir);
+
+ lock (Gate)
+ {
+ File.AppendAllText(path, line, new UTF8Encoding(false));
+ MaybeRotate(path);
+ }
+ }
+ catch
+ {
+ // Logging must never break the host process.
+ }
+ }
+
+ private static string FormatData(object? data)
+ {
+ if (data == null) return string.Empty;
+ try { return " " + JsonConvert.SerializeObject(data); }
+ catch { return " " + data; }
+ }
+
+ private static void MaybeRotate(string path)
+ {
+ try
+ {
+ var info = new FileInfo(path);
+ if (!info.Exists || info.Length <= MaxLogBytes) return;
+
+ byte[] tail = new byte[TruncateKeepBytes];
+ int read;
+ using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read))
+ {
+ fs.Seek(info.Length - TruncateKeepBytes, SeekOrigin.Begin);
+ read = fs.Read(tail, 0, TruncateKeepBytes);
+ }
+
+ using (var fs = new FileStream(path, FileMode.Create, FileAccess.Write))
+ {
+ fs.Write(tail, 0, read);
+ }
+ }
+ catch
+ {
+ // Rotation failure is non-fatal.
+ }
+ }
+ }
+}
diff --git a/visualstudio-extension/DevGlobe/Properties/AssemblyInfo.cs b/visualstudio-extension/DevGlobe/Properties/AssemblyInfo.cs
new file mode 100644
index 0000000..fea9b0d
--- /dev/null
+++ b/visualstudio-extension/DevGlobe/Properties/AssemblyInfo.cs
@@ -0,0 +1,16 @@
+using System.Reflection;
+using System.Runtime.InteropServices;
+
+[assembly: AssemblyTitle("DevGlobe")]
+[assembly: AssemblyDescription("Show your live coding presence on the DevGlobe world map.")]
+[assembly: AssemblyConfiguration("")]
+[assembly: AssemblyCompany("DevGlobe")]
+[assembly: AssemblyProduct("DevGlobe")]
+[assembly: AssemblyCopyright("Copyright © DevGlobe")]
+[assembly: AssemblyTrademark("")]
+[assembly: AssemblyCulture("")]
+
+[assembly: ComVisible(false)]
+
+[assembly: AssemblyVersion("0.1.0.0")]
+[assembly: AssemblyFileVersion("0.1.0.0")]
diff --git a/visualstudio-extension/DevGlobe/Resources/DevGlobe.ico b/visualstudio-extension/DevGlobe/Resources/DevGlobe.ico
new file mode 100644
index 0000000..0225949
Binary files /dev/null and b/visualstudio-extension/DevGlobe/Resources/DevGlobe.ico differ
diff --git a/visualstudio-extension/DevGlobe/Resources/DevGlobePackage.Commands.vsct b/visualstudio-extension/DevGlobe/Resources/DevGlobePackage.Commands.vsct
new file mode 100644
index 0000000..ef7aef7
--- /dev/null
+++ b/visualstudio-extension/DevGlobe/Resources/DevGlobePackage.Commands.vsct
@@ -0,0 +1,142 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ DefaultDocked
+
+ DevGlobe
+ DevGlobe
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Set Status Message
+ DevGlobe.SetStatus
+
+
+
+
+
+
+ Show Coding Time
+ DevGlobe.ShowCodingTime
+
+
+
+
+
+
+ Open Globe
+ DevGlobe.OpenGlobe
+
+
+
+
+
+
+ Debug
+ DevGlobe.Debug
+
+
+
+
+
+
+ Open Log File…
+ DevGlobe.OpenLogFile
+
+
+
+
+
+
+ Open Config File…
+ DevGlobe.OpenConfigFile
+
+
+
+
+
+
+
+ IconIsMoniker
+
+ DevGlobe
+ DevGlobe.OpenPanel
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/visualstudio-extension/DevGlobe/StatusBar.cs b/visualstudio-extension/DevGlobe/StatusBar.cs
new file mode 100644
index 0000000..d941274
--- /dev/null
+++ b/visualstudio-extension/DevGlobe/StatusBar.cs
@@ -0,0 +1,51 @@
+using Microsoft.VisualStudio.Shell;
+using Microsoft.VisualStudio.Shell.Interop;
+
+namespace DevGlobe
+{
+ ///
+ /// Shows today's coding time in the VS status bar via IVsStatusbar.
+ ///
+ public sealed class DevGlobeStatusBar
+ {
+ private readonly IVsStatusbar _statusbar;
+
+ public DevGlobeStatusBar(IVsStatusbar statusbar)
+ {
+ _statusbar = statusbar;
+ }
+
+ /// Displays "⏱ 2h 15m".
+ public void UpdateTime(long todaySeconds)
+ {
+ ThreadHelper.ThrowIfNotOnUIThread();
+
+ string label = "⏱ " + TimeFormat.Format(todaySeconds);
+
+ // Unfreeze the text area in case another component froze it.
+ int frozen;
+ _statusbar.IsFrozen(out frozen);
+ if (frozen != 0)
+ {
+ _statusbar.FreezeOutput(0);
+ }
+
+ _statusbar.SetText(label);
+ }
+
+ /// Clears the status bar text.
+ public void Hide()
+ {
+ ThreadHelper.ThrowIfNotOnUIThread();
+
+ int frozen;
+ _statusbar.IsFrozen(out frozen);
+ if (frozen != 0)
+ {
+ _statusbar.FreezeOutput(0);
+ }
+
+ _statusbar.Clear();
+ }
+ }
+}
diff --git a/visualstudio-extension/DevGlobe/TimeFormat.cs b/visualstudio-extension/DevGlobe/TimeFormat.cs
new file mode 100644
index 0000000..24cc9fc
--- /dev/null
+++ b/visualstudio-extension/DevGlobe/TimeFormat.cs
@@ -0,0 +1,23 @@
+namespace DevGlobe
+{
+ ///
+ /// Formats a duration in seconds as a short label ("2h 15m" / "15m").
+ ///
+ public static class TimeFormat
+ {
+ public static string Format(long todaySeconds)
+ {
+ if (todaySeconds < 0)
+ {
+ todaySeconds = 0;
+ }
+
+ long hours = todaySeconds / 3600;
+ long minutes = (todaySeconds % 3600) / 60;
+
+ return hours > 0
+ ? $"{hours}h {minutes}m"
+ : $"{minutes}m";
+ }
+ }
+}
diff --git a/visualstudio-extension/DevGlobe/ToolWindow/DashboardView.xaml b/visualstudio-extension/DevGlobe/ToolWindow/DashboardView.xaml
new file mode 100644
index 0000000..5c4584e
--- /dev/null
+++ b/visualstudio-extension/DevGlobe/ToolWindow/DashboardView.xaml
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/visualstudio-extension/DevGlobe/ToolWindow/DashboardView.xaml.cs b/visualstudio-extension/DevGlobe/ToolWindow/DashboardView.xaml.cs
new file mode 100644
index 0000000..893c060
--- /dev/null
+++ b/visualstudio-extension/DevGlobe/ToolWindow/DashboardView.xaml.cs
@@ -0,0 +1,68 @@
+using System;
+using System.Windows.Controls;
+using System.Windows.Input;
+
+namespace DevGlobe.ToolWindow
+{
+ ///
+ /// Dashboard view. ApplyState() updates the displayed state when configured.
+ ///
+ public partial class DashboardView : UserControl
+ {
+ public event Action StartRequested;
+ public event Action StopRequested;
+ public event Action SetStatusRequested;
+ public event Action DisconnectRequested;
+
+ public DashboardView()
+ {
+ InitializeComponent();
+ }
+
+ ///
+ /// Pushes state into the view: coding time, language, and the enabled state of the buttons.
+ ///
+ public void ApplyState(TrackerState state)
+ {
+ CodingTimeText.Text = string.IsNullOrEmpty(state.CodingTime) ? "0m" : state.CodingTime;
+ LanguageText.Text = string.IsNullOrEmpty(state.Language) ? "--" : state.Language;
+ StopButton.IsEnabled = state.Tracking;
+ StartButton.IsEnabled = !state.Tracking;
+ }
+
+ private void StatusBox_KeyDown(object sender, KeyEventArgs e)
+ {
+ if (e.Key == Key.Enter)
+ {
+ RaiseSetStatus();
+ e.Handled = true;
+ }
+ }
+
+ private void SetStatusButton_Click(object sender, System.Windows.RoutedEventArgs e)
+ {
+ RaiseSetStatus();
+ }
+
+ private void RaiseSetStatus()
+ {
+ // Send the message as-is; the core validates and trims it.
+ SetStatusRequested?.Invoke(StatusBox.Text ?? string.Empty);
+ }
+
+ private void StartButton_Click(object sender, System.Windows.RoutedEventArgs e)
+ {
+ StartRequested?.Invoke();
+ }
+
+ private void StopButton_Click(object sender, System.Windows.RoutedEventArgs e)
+ {
+ StopRequested?.Invoke();
+ }
+
+ private void DisconnectLink_Click(object sender, System.Windows.RoutedEventArgs e)
+ {
+ DisconnectRequested?.Invoke();
+ }
+ }
+}
diff --git a/visualstudio-extension/DevGlobe/ToolWindow/DevGlobeToolWindow.cs b/visualstudio-extension/DevGlobe/ToolWindow/DevGlobeToolWindow.cs
new file mode 100644
index 0000000..8175a5a
--- /dev/null
+++ b/visualstudio-extension/DevGlobe/ToolWindow/DevGlobeToolWindow.cs
@@ -0,0 +1,25 @@
+using System.Runtime.InteropServices;
+using Microsoft.VisualStudio.Shell;
+
+namespace DevGlobe.ToolWindow
+{
+ ///
+ /// DevGlobe tool window hosting the WPF control that toggles between Login and Dashboard.
+ /// Registered via [ProvideToolWindow] and instantiated by the VS shell (hence the
+ /// parameterless constructor). The control binds itself to ,
+ /// so it stays functional even when VS recreates the window without going through the package.
+ ///
+ [Guid(WindowGuidString)]
+ public sealed class DevGlobeToolWindow : ToolWindowPane
+ {
+ // Stable GUID (contract). Must match the value used in the package and .vsct.
+ public const string WindowGuidString = "6a4b0d3f-8c25-4f9b-ae7a-2b3c4d5e6f70";
+
+ /// Parameterless constructor required by the VS shell.
+ public DevGlobeToolWindow() : base(null)
+ {
+ Caption = "DevGlobe";
+ Content = new DevGlobeToolWindowControl();
+ }
+ }
+}
diff --git a/visualstudio-extension/DevGlobe/ToolWindow/DevGlobeToolWindowControl.xaml b/visualstudio-extension/DevGlobe/ToolWindow/DevGlobeToolWindowControl.xaml
new file mode 100644
index 0000000..f8b5e2f
--- /dev/null
+++ b/visualstudio-extension/DevGlobe/ToolWindow/DevGlobeToolWindowControl.xaml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
diff --git a/visualstudio-extension/DevGlobe/ToolWindow/DevGlobeToolWindowControl.xaml.cs b/visualstudio-extension/DevGlobe/ToolWindow/DevGlobeToolWindowControl.xaml.cs
new file mode 100644
index 0000000..1dc2f4c
--- /dev/null
+++ b/visualstudio-extension/DevGlobe/ToolWindow/DevGlobeToolWindowControl.xaml.cs
@@ -0,0 +1,115 @@
+using System;
+using System.Threading.Tasks;
+using System.Windows.Controls;
+
+namespace DevGlobe.ToolWindow
+{
+ ///
+ /// WPF host for both views. Toggles Login/Dashboard based on Configured and relays
+ /// actions through . The package shell is the source of truth
+ /// (rather than a one-time pushed state), so buttons stay active even if VS recreates this window.
+ ///
+ public partial class DevGlobeToolWindowControl : UserControl
+ {
+ public DevGlobeToolWindowControl()
+ {
+ InitializeComponent();
+
+ // Wire view events to local handlers.
+ LoginViewControl.ConnectRequested += OnConnectRequested;
+ DashboardViewControl.StartRequested += OnStartRequested;
+ DashboardViewControl.StopRequested += OnStopRequested;
+ DashboardViewControl.SetStatusRequested += OnSetStatusRequested;
+ DashboardViewControl.DisconnectRequested += OnDisconnectRequested;
+
+ // Subscribe to the package shell (source of truth), resilient to window recreation.
+ DevGlobeShell.Instance.StateChanged += OnShellStateChanged;
+ Unloaded += (s, e) => DevGlobeShell.Instance.StateChanged -= OnShellStateChanged;
+
+ // Initial state (defaults to Login when no client exists yet).
+ UpdateState(DevGlobeShell.Instance.CurrentState ?? new TrackerState());
+ }
+
+ private void OnShellStateChanged(TrackerState state)
+ {
+ if (Dispatcher.CheckAccess()) UpdateState(state);
+ else Dispatcher.Invoke(() => UpdateState(state));
+ }
+
+ ///
+ /// Pushes state into the UI: if configured show Dashboard, otherwise show Login (cleared field).
+ ///
+ public void UpdateState(TrackerState state)
+ {
+ if (state == null)
+ {
+ return;
+ }
+
+ if (state.Configured)
+ {
+ LoginViewControl.Visibility = System.Windows.Visibility.Collapsed;
+ DashboardViewControl.Visibility = System.Windows.Visibility.Visible;
+ DashboardViewControl.ApplyState(state);
+ }
+ else
+ {
+ DashboardViewControl.Visibility = System.Windows.Visibility.Collapsed;
+ LoginViewControl.Visibility = System.Windows.Visibility.Visible;
+ LoginViewControl.Clear();
+ }
+ }
+
+ private async void OnConnectRequested(string key)
+ {
+ var onConnect = DevGlobeShell.Instance.OnConnect;
+ if (onConnect == null)
+ {
+ return;
+ }
+
+ try
+ {
+ await onConnect(key);
+ }
+ catch (Exception ex)
+ {
+ Log.Info("ToolWindow: connect failed", ex.Message);
+ }
+ }
+
+ // Start/Stop go through the package shell (like connect/disconnect) to persist
+ // tracking_enabled and show the notification.
+ private void OnStartRequested()
+ {
+ var onStart = DevGlobeShell.Instance.OnStart;
+ if (onStart != null) _ = onStart();
+ }
+
+ private void OnStopRequested()
+ {
+ var onStop = DevGlobeShell.Instance.OnStop;
+ if (onStop != null) _ = onStop();
+ }
+
+ private void OnSetStatusRequested(string message) => DevGlobeShell.Instance.Client?.SetStatus(message);
+
+ private async void OnDisconnectRequested()
+ {
+ var onDisconnect = DevGlobeShell.Instance.OnDisconnect;
+ if (onDisconnect == null)
+ {
+ return;
+ }
+
+ try
+ {
+ await onDisconnect();
+ }
+ catch (Exception ex)
+ {
+ Log.Info("ToolWindow: disconnect failed", ex.Message);
+ }
+ }
+ }
+}
diff --git a/visualstudio-extension/DevGlobe/ToolWindow/LoginView.xaml b/visualstudio-extension/DevGlobe/ToolWindow/LoginView.xaml
new file mode 100644
index 0000000..4d8bd9e
--- /dev/null
+++ b/visualstudio-extension/DevGlobe/ToolWindow/LoginView.xaml
@@ -0,0 +1,27 @@
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/visualstudio-extension/DevGlobe/ToolWindow/LoginView.xaml.cs b/visualstudio-extension/DevGlobe/ToolWindow/LoginView.xaml.cs
new file mode 100644
index 0000000..0d87564
--- /dev/null
+++ b/visualstudio-extension/DevGlobe/ToolWindow/LoginView.xaml.cs
@@ -0,0 +1,72 @@
+using System;
+using System.Diagnostics;
+using System.Windows.Controls;
+using System.Windows.Input;
+using System.Windows.Navigation;
+
+namespace DevGlobe.ToolWindow
+{
+ ///
+ /// Login view. Raises ConnectRequested with the entered key; does not touch core or config.
+ ///
+ public partial class LoginView : UserControl
+ {
+ /// Raised when the user submits a non-empty key.
+ public event Action ConnectRequested;
+
+ public LoginView()
+ {
+ InitializeComponent();
+ }
+
+ /// Clears the field and restores focus when switching back to Login.
+ public void Clear()
+ {
+ KeyBox.Clear();
+ KeyBox.Focus();
+ }
+
+ private void ConnectButton_Click(object sender, System.Windows.RoutedEventArgs e)
+ {
+ Submit();
+ }
+
+ private void KeyBox_KeyDown(object sender, KeyEventArgs e)
+ {
+ if (e.Key == Key.Enter)
+ {
+ Submit();
+ e.Handled = true;
+ }
+ }
+
+ private void Submit()
+ {
+ string key = (KeyBox.Password ?? string.Empty).Trim();
+ if (key.Length == 0)
+ {
+ return;
+ }
+
+ ConnectRequested?.Invoke(key);
+ }
+
+ private void GetKeyLink_RequestNavigate(object sender, RequestNavigateEventArgs e)
+ {
+ try
+ {
+ Process.Start(new ProcessStartInfo
+ {
+ FileName = e.Uri.AbsoluteUri,
+ UseShellExecute = true,
+ });
+ }
+ catch (Exception ex)
+ {
+ Log.Info("LoginView: failed to open browser", ex.Message);
+ }
+
+ e.Handled = true;
+ }
+ }
+}
diff --git a/visualstudio-extension/DevGlobe/TrackerState.cs b/visualstudio-extension/DevGlobe/TrackerState.cs
new file mode 100644
index 0000000..5350891
--- /dev/null
+++ b/visualstudio-extension/DevGlobe/TrackerState.cs
@@ -0,0 +1,28 @@
+namespace DevGlobe
+{
+ ///
+ /// Observable tracker state pushed to the tool window and status bar on every change.
+ /// Shared across CoreClient, DevGlobePackage, DevGlobeToolWindow, DashboardView and
+ /// DevGlobeStatusBar.
+ ///
+ public sealed class TrackerState
+ {
+ /// API key is present and the core is configured.
+ public bool Configured { get; set; }
+
+ /// Tracking is active (heartbeats in progress).
+ public bool Tracking { get; set; }
+
+ /// Today's coding time, formatted for display (e.g. "2h 15m").
+ public string CodingTime { get; set; } = "0m";
+
+ /// Today's coding time in seconds.
+ public long TodaySeconds { get; set; }
+
+ /// Detected active language, or null if unknown.
+ public string? Language { get; set; }
+
+ /// Core is offline (network failure, awaiting reconnect).
+ public bool Offline { get; set; }
+ }
+}
diff --git a/visualstudio-extension/DevGlobe/VSPackage.resx b/visualstudio-extension/DevGlobe/VSPackage.resx
new file mode 100644
index 0000000..f054b2e
--- /dev/null
+++ b/visualstudio-extension/DevGlobe/VSPackage.resx
@@ -0,0 +1,67 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ text/microsoft-resx
+
+
+ 2.0
+
+
+ System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
+ System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089
+
+
diff --git a/visualstudio-extension/DevGlobe/source.extension.vsixmanifest b/visualstudio-extension/DevGlobe/source.extension.vsixmanifest
new file mode 100644
index 0000000..b425630
--- /dev/null
+++ b/visualstudio-extension/DevGlobe/source.extension.vsixmanifest
@@ -0,0 +1,30 @@
+
+
+
+
+ DevGlobe
+ Show your live coding presence on the DevGlobe world map. Track coding time, languages, repos, and stats.
+ https://devglobe.app
+ Resources\DevGlobe.ico
+ devglobe;globe;coding time;time tracking;coding stats;presence;wakatime
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/visualstudio-extension/README.md b/visualstudio-extension/README.md
new file mode 100644
index 0000000..d2f35f6
--- /dev/null
+++ b/visualstudio-extension/README.md
@@ -0,0 +1,113 @@
+DevGlobe for Visual Studio
+
+
+ Show up on a 3D globe in real time while you code.
+ Your activity is displayed live on devglobe.app — other developers see you, discover your projects and your links.
+
+
+
+ Releases (.vsix) ·
+ devglobe.app ·
+ Source code
+
+
+---
+
+> **Open source & transparent** — This extension is 100% open source. No code is read, no sensitive data is collected. You can audit every line on [GitHub](https://github.com/Nako0/devglobe-extension).
+
+---
+
+## How it works
+
+1. Sign in on [devglobe.app](https://devglobe.app) with GitHub, X (Twitter), or Google
+2. Copy your API key from the site settings
+3. Open the **DevGlobe** panel — click the **globe button** in the toolbar, or **View → Other Windows → DevGlobe**
+4. Paste your API key → **Connect**
+5. You're online — your marker appears on the globe
+
+The extension sends a **heartbeat every 30 seconds** as long as you're actively coding. It pauses after 1 minute of inactivity. **After 15 minutes of inactivity, you disappear from the globe.**
+
+On first launch, the extension downloads the matching `devglobe-core` binary for Windows from [GitHub Releases](https://github.com/Nako0/devglobe-extension/releases) (one-time) and caches it under `%LOCALAPPDATA%\DevGlobe\core`.
+
+Visibility settings (anonymous mode, repo sharing, profile mode) are managed on [devglobe.app/dashboard/settings](https://devglobe.app/dashboard/settings).
+
+---
+
+## Features
+
+| Feature | Description |
+|---------|-------------|
+| **Live heartbeat** | Sends your activity every 30s. Auto-pauses after 1 min of inactivity. |
+| **Language detection** | Detects 150+ languages from your active editor document. |
+| **Platform detection** | Sends your OS (Windows) alongside each heartbeat so it appears on your profile. |
+| **Git integration** | Detects your repo from the git remote. Commit data is never read or sent by the extension. |
+| **Status message** | Write what you're working on — visible on your globe profile. |
+| **Status bar** | Displays your coding time for today (e.g. `2h 15m`) in the Visual Studio status bar. |
+
+### Tool window
+
+Two views in the DevGlobe tool window:
+
+- **Login** — masked API key field + link to get your key on devglobe.app
+- **Dashboard** — live coding time, active language, status message, start/stop buttons, disconnect
+
+### Commands
+
+Accessible from **Tools → DevGlobe**:
+
+| Command | Description |
+|---------|-------------|
+| `Set Status Message` | Set your status message on the globe |
+| `Show Coding Time` | Show your coding time today |
+| `Open Globe` | Open [devglobe.app/space](https://devglobe.app/space) in your browser |
+| `Debug` | Toggle debug logging in `~/.devglobe/devglobe.log` |
+| `Open Log File…` | Open `~/.devglobe/devglobe.log` |
+| `Open Config File…` | Open `~/.devglobe/config.toml` |
+
+---
+
+## What DevGlobe brings you
+
+- **Enhanced public profile** — Your GitHub, X, projects, activity, tech stack and links on a single shareable page.
+- **Project directory** — Publish your projects, invite teammates, get discovered and upvoted by the community.
+- **Comments & upvotes** — Threaded discussions on every project. Give and get feedback from other developers.
+- **Developer dashboard** — One place to manage your profile, projects and extensions, track coding stats, unlock badges and read notifications.
+- **Discovery** — Browse and filter developers & projects by language, tools and platform.
+- **Networking** — See who's coding right now and in which language. Click a marker to discover a developer, their projects and their links.
+- **Light & dark mode** — Full theme support across the platform.
+
+---
+
+## Privacy
+
+The extension sends programming language, editor name, OS, coding time, the origin remote URL of your current git repo (when present), branch name, and the file path **relative to your repo root** — never an absolute home path.
+
+Files outside any git repository are not tracked beyond their language. We never read source code, file contents, keystrokes, or commit messages.
+
+Local privacy flags can be toggled in `~/.devglobe/config.toml` under `[privacy]`: `hide_file_names`, `hide_branch_names`, `hide_project_names` (the project flag also hides branches).
+
+Globe-side visibility (anonymous mode, repo sharing on the live globe, profile mode) is managed on [devglobe.app/dashboard/settings](https://devglobe.app/dashboard/settings).
+
+API keys are stored in the Windows Credential Manager — never in plain text. The core reads the key from `%USERPROFILE%\.devglobe\config.toml` (created with restrictive permissions).
+
+**Network:** HTTPS only (TLS 1.2+), no telemetry, no third-party trackers.
+
+---
+
+## Requirements
+
+- **Visual Studio 2022 (17.x)** or **Visual Studio 2026 (18.x)** on Windows
+- Not to be confused with **VS Code** — see the [DevGlobe for VS Code](https://marketplace.visualstudio.com/items?itemName=devglobe.devglobe) extension for that editor
+
+---
+
+## Links
+
+- [devglobe.app/space](https://devglobe.app/space) — the globe
+- [Source code](https://github.com/Nako0/devglobe-extension) — public GitHub repo
+
+---
+
+
+ devglobe.app
+