Visual Studio 2022 で「Window → New Window」相当の複製タブを作り、すぐ隣のタブグループへ自動で移したい──この一連の操作を C# 製 VSIX で確実に再現するための実装手順を、API 解説から堅牢なサンプルコード、落とし穴の回避策までまとめました。IDE の UI スレッドや DTE コマンドの扱いも丁寧に整理しています。
質問の背景とゴール
IDE 上で開いたドキュメント(例:main.cpp)を複製して main.cpp:2 のような「コピー」を生成し、生成直後の複製タブを隣のドキュメント・タブグループへ自動で移動させたい。ユーザー操作では「Window → New Window」→「Window → Move to Next Tab Group」の連続実行で実現できますが、拡張機能から同等の体験をワンクリックで提供するのが目的です。
結論(最短レシピ)
以下の 3 ステップで安定して実現できます。
| 手順 | 具体的 API/操作 | ポイント |
|---|---|---|
| 複製を作成 | IVsUIShellOpenDocument.OpenCopyOfStandardEditor | 既存 IVsWindowFrame から file:2 などのコピーを生成(New Window 相当)。logicalView は通常 VSConstants.LOGVIEWID_Primary。 |
| 複製タブの取得 | cloneFrame.Show() → cloneFrame.GetProperty(VSFPROPID_ExtWindowObject) | EnvDTE.Window を取得できるため、以降は DTE コマンドを利用可能。 |
| タブを移動 | window.Activate() → DTE.ExecuteCommand("Window.MoveToNextTabGroup") | IDE メニュー「Move to Next Tab Group」と同等。左側へ送る場合は "Window.MoveToPreviousTabGroup"。 |
最小コード(抜粋)
public async Task DuplicateIntoOtherGroupAsync(IVsWindowFrame sourceFrame)
{
// UI スレッドで COM を触る
await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync();
// 1. 複製を生成
var openDoc = await AsyncServiceProvider.GlobalProvider
.GetServiceAsync(typeof(SVsUIShellOpenDocument))
as IVsUIShellOpenDocument;
Guid view = VSConstants.LOGVIEWID_Primary;
ErrorHandler.ThrowOnFailure(
openDoc.OpenCopyOfStandardEditor(sourceFrame, ref view, out var cloneFrame)
);
// 2. 複製タブを表示・取得
ErrorHandler.ThrowOnFailure(cloneFrame.Show());
ErrorHandler.ThrowOnFailure(cloneFrame.GetProperty(
(int)__VSFPROPID.VSFPROPID_ExtWindowObject, out object winObj));
var win = (EnvDTE.Window)winObj;
// 3. 別グループへ移動
win.Activate(); // アクティブ化
var dte = (EnvDTE80.DTE2)Package.GetGlobalService(typeof(SDTE));
dte.ExecuteCommand("Window.MoveToNextTabGroup");
}
実装手順(詳細解説)
前提:AsyncPackage と UI スレッド
Visual Studio の多くのサービスは UI スレッド(メインスレッド)での呼び出しが前提です。VSIX は AsyncPackage(非同期パッケージ)で実装し、COM へ触れる直前で SwitchToMainThreadAsync() を呼び出して UI スレッドへ戻します。これにより応答性を損なわずに実装できます。
[PackageRegistration(UseManagedResourcesOnly = true, AllowsBackgroundLoading = true)]
[InstalledProductRegistration("DuplicateAndMove", "Duplicate document and move to next group", "1.0")]
[ProvideMenuResource("Menus.ctmenu", 1)]
public sealed class DuplicateAndMovePackage : AsyncPackage
{
protected override async Task InitializeAsync(CancellationToken cancellationToken, IProgress<ServiceProgressData> progress)
{
await this.JoinableTaskFactory.SwitchToMainThreadAsync();
await DuplicateAndMoveCommand.InitializeAsync(this);
}
}
アクティブドキュメントの IVsWindowFrame を取得する
メニューコマンドなど「現在アクティブなドキュメント」を対象にする場合、まず DTE でパスを取り、VsShellUtilities.IsDocumentOpen から IVsWindowFrame を得るのがシンプルです。
private static async Task<IVsWindowFrame> GetActiveDocumentFrameAsync(AsyncPackage package)
{
await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync();
var dte = (EnvDTE80.DTE2)await package.GetServiceAsync(typeof(SDTE));
var doc = dte?.ActiveDocument;
if (doc == null) return null;
IVsUIHierarchy hier;
IVsWindowFrame frame;
uint itemid;
bool isOpen = VsShellUtilities.IsDocumentOpen(
package, doc.FullName, Guid.Empty, out hier, out itemid, out frame);
return isOpen ? frame : null;
}
すでに IVsWindowFrame を引数でもらえる設計(エディタごとのコンテキストメニューなど)の場合は、この取得処理は不要です。
複製の作成:OpenCopyOfStandardEditor
複製(New Window 相当)は IVsUIShellOpenDocument.OpenCopyOfStandardEditor を使います。logicalView に VSConstants.LOGVIEWID_Primary を指定すれば通常のコードエディタのコピーが得られます。デザイナ等の別ビューをコピーしたい場合は該当 GUID を使います(後述の表を参照)。
Guid logicalView = VSConstants.LOGVIEWID_Primary;
var openDoc = await package.GetServiceAsync(typeof(SVsUIShellOpenDocument)) as IVsUIShellOpenDocument;
ErrorHandler.ThrowOnFailure(openDoc.OpenCopyOfStandardEditor(sourceFrame, ref logicalView, out var cloneFrame));
複製タブの DTE ラッパーを取得する
作成した IVsWindowFrame から VSFPROPID_ExtWindowObject を取り出すと EnvDTE.Window が得られます。DTE コマンドを使う際はこのラッパーが便利です。
ErrorHandler.ThrowOnFailure(cloneFrame.Show());
ErrorHandler.ThrowOnFailure(cloneFrame.GetProperty(
(int)__VSFPROPID.VSFPROPID_ExtWindowObject, out object obj));
var dteWindow = (EnvDTE.Window)obj;
タブを隣のタブグループへ移動する
対象ウィンドウを Activate() したうえで、DTE コマンド Window.MoveToNextTabGroup を実行します。隣ではなく左側へ送りたい場合は Window.MoveToPreviousTabGroup を使います。
dteWindow.Activate();
var dte = (EnvDTE80.DTE2)await package.GetServiceAsync(typeof(SDTE));
dte.ExecuteCommand("Window.MoveToNextTabGroup");
注意点として、環境によっては隣のタブグループが存在しないとコマンドが無効な場合があります。その際は先に Window.NewVerticalTabGroup(または Window.NewHorizontalTabGroup)を実行してグループを作ってから移動させると確実です。
bool TryExec(EnvDTE80.DTE2 d, string name)
{
try
{
var cmd = d.Commands.Item(name);
if (cmd != null && cmd.IsAvailable)
{
d.ExecuteCommand(name);
return true;
}
}
catch { }
return false;
}
// 利用例
dteWindow.Activate();
if (!TryExec(dte, "Window.MoveToNextTabGroup"))
{
// 隣が無ければ縦のタブグループを作る
TryExec(dte, "Window.NewVerticalTabGroup");
TryExec(dte, "Window.MoveToNextTabGroup");
}
フル実装:ワンクリックで「複製 → 隣へ移動」
メニューコマンドから起動できる形で、例外処理や UI スレッド制御を含めた完全版です。
internal sealed class DuplicateAndMoveCommand
{
public const int CommandId = 0x0200;
public static readonly Guid CommandSet = new Guid("b0d9f0c3-0f76-4a2e-8f2e-8a4e77e09a42");
private readonly AsyncPackage _package;
private DuplicateAndMoveCommand(AsyncPackage package, OleMenuCommandService commandService)
{
_package = package;
var cmdId = new CommandID(CommandSet, CommandId);
var menuItem = new OleMenuCommand(Execute, cmdId);
commandService.AddCommand(menuItem);
}
public static async Task InitializeAsync(AsyncPackage package)
{
await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync();
var commandService = await package.GetServiceAsync(typeof(IMenuCommandService)) as OleMenuCommandService;
_ = new DuplicateAndMoveCommand(package, commandService);
}
private async void Execute(object sender, EventArgs e)
{
try
{
await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync();
var frame = await GetActiveDocumentFrameAsync(_package);
if (frame == null) return;
await DuplicateIntoOtherGroupAsync(frame);
}
catch (Exception ex)
{
System.Diagnostics.Debug.WriteLine(ex);
}
}
private async Task DuplicateIntoOtherGroupAsync(IVsWindowFrame sourceFrame)
{
await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync();
var openDoc = await _package.GetServiceAsync(typeof(SVsUIShellOpenDocument)) as IVsUIShellOpenDocument;
Assumes.Present(openDoc);
Guid logicalView = VSConstants.LOGVIEWID_Primary;
ErrorHandler.ThrowOnFailure(openDoc.OpenCopyOfStandardEditor(sourceFrame, ref logicalView, out var cloneFrame));
ErrorHandler.ThrowOnFailure(cloneFrame.Show());
ErrorHandler.ThrowOnFailure(cloneFrame.GetProperty((int)__VSFPROPID.VSFPROPID_ExtWindowObject, out object obj));
var win = (EnvDTE.Window)obj;
win.Activate();
var dte = (EnvDTE80.DTE2)await _package.GetServiceAsync(typeof(SDTE));
if (!TryExec(dte, "Window.MoveToNextTabGroup"))
{
// 隣のグループが無い場合は新規作成してから移動
TryExec(dte, "Window.NewVerticalTabGroup");
TryExec(dte, "Window.MoveToNextTabGroup");
}
}
private static bool TryExec(EnvDTE80.DTE2 d, string name)
{
try
{
var cmd = d.Commands.Item(name);
if (cmd != null && cmd.IsAvailable)
{
d.ExecuteCommand(name);
return true;
}
}
catch { }
return false;
}
private static async Task<IVsWindowFrame> GetActiveDocumentFrameAsync(AsyncPackage package)
{
await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync();
var dte = (EnvDTE80.DTE2)await package.GetServiceAsync(typeof(SDTE));
var doc = dte?.ActiveDocument;
if (doc == null) return null;
IVsUIHierarchy hier;
IVsWindowFrame frame;
uint itemid;
bool isOpen = VsShellUtilities.IsDocumentOpen(package, doc.FullName, Guid.Empty, out hier, out itemid, out frame);
return isOpen ? frame : null;
}
}
ビューごとの logicalView と関連プロパティ
コードエディタ以外のビュー(フォームデザイナなど)を複製したいときは、OpenCopyOfStandardEditor の logicalView を差し替えます。代表的な GUID は以下のとおりです。
| 用途 | 定数 | 備考 |
|---|---|---|
| 通常のコード編集 | VSConstants.LOGVIEWID_Primary | もっとも一般的なビュー |
| デザインビュー | VSConstants.LOGVIEWID_Designer | フォームや XAML デザイナなど |
| コードのみ | VSConstants.LOGVIEWID_Code | コードに特化したビュー |
| テキスト | VSConstants.LOGVIEWID_TextView | 基本テキストエディタ |
ウィンドウプロパティを触る際のキーもまとめておきます。
| プロパティ名 | 概要 | 型 |
|---|---|---|
__VSFPROPID.VSFPROPID_ExtWindowObject | DTE ラッパー(EnvDTE.Window) | object |
__VSFPROPID.VSFPROPID_DocView | ドキュメントビュー | object |
__VSFPROPID.VSFPROPID_FrameMode | フレームのモード(ドッキング/フローティングなど) | uint |
DTE コマンド早見表
タブグループ操作に関係する DTE コマンドをまとめます。拡張の動的動作分岐(Command.IsAvailable)の判定にも活用できます。
| コマンド | 動作 | 利用例 |
|---|---|---|
Window.NewWindow | アクティブドキュメントの複製 | OpenCopyOfStandardEditor の代替としても使用可能 |
Window.MoveToNextTabGroup | 右/下のタブグループへ移動 | 本記事のメイン |
Window.MoveToPreviousTabGroup | 左/上のタブグループへ移動 | 左右逆方向の移動に |
Window.NewVerticalTabGroup | 縦に新しいタブグループを作成 | 隣が存在しない場合の前処理 |
Window.NewHorizontalTabGroup | 横に新しいタブグループを作成 | 好みの分割方向に合わせる |
信頼性を高める工夫(実務ノウハウ)
- UI スレッドの厳守: DTE、
IVsWindowFrame、IVsUIShellOpenDocumentを扱う直前にSwitchToMainThreadAsync()を必ず挟む。 - 例外処理:
ErrorHandler.ThrowOnFailure/Assumes.Presentを用いて COM 戻り値とサービス取得の失敗を明示的に扱う。 - 隣のグループ有無の分岐:
Command.IsAvailableで移動コマンドの有効/無効を判定し、無効ならグループを新規作成してから再実行。 - フォーカス制御: 複製後のウィンドウを
Activate()してから移動する。こうすることで「意図したタブのみが移動」される。 - ビュー種別の指定: デザイナ等は
LOGVIEWID_Designerを指定。XAML/WinForms など混在するソリューションで挙動の差が出にくくなる。
拡張:複数グループを跨いで移動する
3 つ以上のグループが存在する場合、必要回数だけ移動コマンドを繰り返し呼び出します。
void MoveAcrossGroups(EnvDTE80.DTE2 dte, int steps, bool previous = false)
{
string cmd = previous ? "Window.MoveToPreviousTabGroup" : "Window.MoveToNextTabGroup";
for (int i = 0; i < steps; i++)
{
if (!TryExec(dte, cmd)) break;
}
}
代替アプローチ:DTE のみで完結させる
「とにかく手早く」実装したい場合、DTE コマンドの連続実行でも目的は達成できます。欠点は、複製後のウィンドウハンドル(IVsWindowFrame)を直接取得できないことです。
await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync();
var dte = (EnvDTE80.DTE2)Package.GetGlobalService(typeof(SDTE));
dte.ExecuteCommand("Window.NewWindow"); // 複製
dte.ExecuteCommand("Window.MoveToNextTabGroup"); // 隣へ移動
UI 依存が強いため、複雑なカスタマイズ(例えば生成直後のウィンドウへタグ付与など)が必要なら前述の OpenCopyOfStandardEditor 方式が適しています。
よくあるハマりどころと対策
- コマンドが無効(IsAvailable = false): 隣のタブグループが存在しない可能性。
Window.NewVerticalTabGroupで作成後に再実行。 - 複製が別ウィンドウとしてフロートしてしまう:
VSFPROPID_FrameModeをチェック。必要に応じてIVsWindowFrameの配置を変更する。 - デザイナの複製でコードビハインドに切り替わる:
logicalViewをLOGVIEWID_Designerに。 - アクティブドキュメントが null: ソリューション エクスプローラーの選択から実行した場合など。アクティブ判定を強化するか、コンテキスト(エディタ上のコマンド)から呼ぶ。
- UI がフリーズする: 重い処理はバックグラウンドで行い、COM や DTE の操作部分のみ UI スレッドに切り戻す。
ユーザー体験を高めるTips
- トグル動作の提供: 「複製して右へ」だけでなく「複製して左へ」「複製のみ」を同一メニュー群で提供し、作業スタイルに合わせた選択を可能に。
- キーボードショートカット: メニューコマンドに適切なショートカットを割り当て、タブ整理の負担を軽減。
- 状態の可視化: ステータスバーへ簡易メッセージ(どのグループへ移したか)を表示して操作のフィードバックを即時に返す。
テスト観点チェックリスト
- 単一/複数ソリューション、マルチプロジェクトでの挙動が同一であること。
- 縦分割/横分割どちらのレイアウトでも正しく移動すること。
- コード、デザイナ、プレビューなど異なる
logicalViewの組み合わせで動作すること。 - 未保存ドキュメントでも複製・移動に失敗しないこと。
- 複数モニター構成(フローティング有り)で意図しない配置にならないこと。
まとめ
Visual Studio 2022 で「ドキュメントを複製して隣のタブグループへ移動」を拡張機能から自動化するには、IVsUIShellOpenDocument.OpenCopyOfStandardEditor による複製生成と、VSFPROPID_ExtWindowObject(EnvDTE.Window)経由の DTE.ExecuteCommand("Window.MoveToNextTabGroup") の組み合わせがもっとも簡潔で堅牢です。隣のグループが無い場合は Window.NewVerticalTabGroup を併用し、UI スレッドのルールを守ることでフリーズや例外を防げます。最小実装から拡張まで、本記事のサンプルをベースにチーム標準の生産的なレイアウト操作を提供してください。
付録:完全版ユーティリティ(コピペ用)
public static class VsTabGroupUtilities
{
public static async Task DuplicateAndMoveToNextAsync(AsyncPackage package, Guid? logicalView = null)
{
await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync();
var frame = await GetActiveFrameAsync(package);
if (frame == null) return;
var openDoc = await package.GetServiceAsync(typeof(SVsUIShellOpenDocument)) as IVsUIShellOpenDocument;
Assumes.Present(openDoc);
Guid view = logicalView ?? VSConstants.LOGVIEWID_Primary;
ErrorHandler.ThrowOnFailure(openDoc.OpenCopyOfStandardEditor(frame, ref view, out var clone));
ErrorHandler.ThrowOnFailure(clone.Show());
ErrorHandler.ThrowOnFailure(clone.GetProperty((int)__VSFPROPID.VSFPROPID_ExtWindowObject, out object winObj));
var win = (EnvDTE.Window)winObj;
win.Activate();
var dte = (EnvDTE80.DTE2)await package.GetServiceAsync(typeof(SDTE));
// 隣へ移動。無ければグループを作成してから移動
if (!TryExec(dte, "Window.MoveToNextTabGroup"))
{
TryExec(dte, "Window.NewVerticalTabGroup");
TryExec(dte, "Window.MoveToNextTabGroup");
}
}
public static async Task DuplicateAndMoveToPreviousAsync(AsyncPackage package, Guid? logicalView = null)
{
await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync();
var frame = await GetActiveFrameAsync(package);
if (frame == null) return;
var openDoc = await package.GetServiceAsync(typeof(SVsUIShellOpenDocument)) as IVsUIShellOpenDocument;
Assumes.Present(openDoc);
Guid view = logicalView ?? VSConstants.LOGVIEWID_Primary;
ErrorHandler.ThrowOnFailure(openDoc.OpenCopyOfStandardEditor(frame, ref view, out var clone));
ErrorHandler.ThrowOnFailure(clone.Show());
ErrorHandler.ThrowOnFailure(clone.GetProperty((int)__VSFPROPID.VSFPROPID_ExtWindowObject, out object winObj));
var win = (EnvDTE.Window)winObj;
win.Activate();
var dte = (EnvDTE80.DTE2)await package.GetServiceAsync(typeof(SDTE));
if (!TryExec(dte, "Window.MoveToPreviousTabGroup"))
{
TryExec(dte, "Window.NewVerticalTabGroup");
TryExec(dte, "Window.MoveToPreviousTabGroup");
}
}
private static async Task<IVsWindowFrame> GetActiveFrameAsync(AsyncPackage package)
{
await ThreadHelper.JoinableTaskFactory.SwitchToMainThreadAsync();
var dte = (EnvDTE80.DTE2)await package.GetServiceAsync(typeof(SDTE));
var doc = dte?.ActiveDocument;
if (doc == null) return null;
IVsUIHierarchy hier;
IVsWindowFrame frame;
uint itemid;
bool isOpen = VsShellUtilities.IsDocumentOpen(package, doc.FullName, Guid.Empty, out hier, out itemid, out frame);
return isOpen ? frame : null;
}
private static bool TryExec(EnvDTE80.DTE2 d, string commandName)
{
try
{
var cmd = d.Commands.Item(commandName);
if (cmd != null && cmd.IsAvailable)
{
d.ExecuteCommand(commandName);
return true;
}
}
catch { }
return false;
}
}
運用メモ
- キー割り当て:Tools → Options → Environment → Keyboard でコマンド ID を割り当てると効果的。
- レイアウト保存:Visual Studio はドキュメントレイアウトを自動保存するため、低レベル API での「状態保存」は不要です。
- プロジェクト混在環境:C++/C#/XAML/WinForms が混在する大規模ソリューションでは、ビュー GUID 指定の明示が安定化に効きます。
補足:なぜ低レベルのレイアウト API を使わないのか
DTE コマンドによる移動は IDE 標準の動作をそのまま流用するため、ウィンドウマネージャやドキュメントグループの内部状態更新・永続化を Visual Studio に委譲できます。結果として拡張側は少ないコードで堅牢に仕上がり、将来バージョンでも壊れにくくなります。

コメント