Windows PowerShell — 山内陽@portfolio\focus-areas\03
PS C:\Users\yamauchi\portfolio\focus-areas\03>

#

自分が毎日つまずいていたことを、道具にして片付けます。作るときに一番考えたのは、うまくいかなかったときに使う人が迷わないことです。

§01

DesktopOrganizer

PS> Get-Item .\DesktopOrganizer | Format-List *

デスクトップに、アイコンをまとめる「棚」を置きます。フォルダの中身を映す棚、最近使ったファイルの棚、時計やメモの棚まで。

デスクトップに、ゲーム・VR・ツール・エディタ・ボカロの棚とアナログ時計の棚が並んでいる
用途ごとの棚と、アナログ時計の棚。棚の色と大きさは自由に変えられます
8日最初のコミットからv1.0.3まで
3回のリリース
7自動テスト
  • 2025.12〜2026.01
  • 個人開発
  • C# / WPF / .NET 8
DesktopOrganizerの画面と技術的な詳細を読む(設計・つまずき・コード)

1使っている画面

D-3左と同じデスクトップに棚を置いて、アイコンを入れた状態静止画 PNG → images/works/desktoporganizer/02-after.png
デスクトップに、用途ごとの棚を置く。アプリも資料も時計も、置きたいものを置きたい場所に
D-4編集モードに切り替えて、棚をドラッグで動かしているところ動画 MP4(無音・5〜10秒) → images/works/desktoporganizer/03-edit-mode.mp4
棚を動かすのは、編集モードのときだけ
D-5フォルダ同期の棚と、その設定の画面静止画 PNG → images/works/desktoporganizer/04-folder-shelf.png
フォルダの中身を、そのまま棚に映します
D-6タスクトレイのアイコンから開くメニュー静止画 PNG → images/works/desktoporganizer/05-tray.png
常駐していて、タスクトレイから操作します

2作品の詳細

7種類の棚

手で並べる棚のほかに、中身を自分で集めてくる棚があります。デスクトップを片付けるだけでなく、デスクトップに置いておきたいものを置けるようにしました。

棚中身
通常ドラッグで入れたアイコンを、好きな順に並べる
フォルダ同期指定したフォルダの中身をそのまま映す。*.pdf のようなパターンで絞れて、フォルダの変化を監視して追従する
最近使ったものWindowsの「最近使ったファイル」を監視して、新しい順に20件まで出す
一時置き入れてから24時間たつと、自動で消える。1分ごとに期限を確認する
メモちょっとしたテキストを書いておく
時計時刻を数字で出す
アナログ時計時刻を針で出す

デスクトップの邪魔をしない

見るときと、動かすときを分ける

ふだんは棚をドラッグしても動きません。棚の配置を変えるときだけ編集モードに切り替えます。うっかり棚を動かしてしまうことがありません。

フォーカスを奪わない

棚のウィンドウには、Win32のウィンドウ属性でフォーカスを奪わない設定(WS_EX_NOACTIVATE)と、Alt+Tabに出てこない設定(WS_EX_TOOLWINDOW)を付けました。作業中のウィンドウから入力を横取りしません。

アイコンが多くても、重くしない

見えている分だけ作る

棚の中身は、自作の VirtualizingWrapPanel で並べます。スクロールしないと見えないアイコンは、画面の部品を作りません。

アイコンはキャッシュする

一度取り出したアイコンは500件まで覚えておきます。古いものから捨てるLRU方式で、出し入れはどちらも件数に関係なく一定の手間で済みます(O(1))。

取り出しは同時に4つまで

アイコンの取り出しは非同期で、同時に走るのは4つまでに絞りました。256px、48px、32pxの順に、取れる一番大きいものを使います。

3こだわったこと

モニターをつなぎ替えても、棚は同じ場所にある

やりたかったこと

ノートPCを外部モニターにつないだり外したりしても、棚がいつもの場所に戻ってきます。デスクトップの道具は、置いた場所が変わらないことがいちばん大事だと考えました。

ぶつかった壁

棚の位置をピクセルで保存すると、解像度やタスクバーの位置が変わっただけでずれてしまいます。

棚があったモニターが外されると、棚ごと見えない場所に行ってしまいます。

どう作ったか

棚の位置は、ピクセルではなくモニターの作業領域に対する割合で保存します。表示するときに、そのときの作業領域の大きさを掛けてピクセルに戻します。

タスクバー X 0.6 Y 0.2 幅 0.2 · 高さ 0.3 作業領域(タスクバーを除いた範囲)に対する割合 メイン 外されたモニター 同じモニターが見つからなければ、メインへ
左:位置は割合で持ちます。右:棚がいたモニターが見つからなければ、メインへ移します。

棚がどのモニターにいたかは、Windowsのデバイス名で覚えておきます。表示するときは、まず同じデバイス名のモニターを探し、無ければメイン、それも無ければ最初に見つかったモニターに置きます。

どうなったか

解像度やタスクバーが変わっても、棚は同じ場所に出ます。モニターを外しても、棚は見える場所に移ってきます。

つまずいたところつかんだ棚が、マウスから逃げていく
起きたこと

編集モードで棚をドラッグすると、動かすほど、つかんだ位置と棚がずれていきました。

原因

ドラッグの始点と今の位置を、別々の物差しで測っていました。画面の実際のピクセルと、WPF の論理ピクセル(表示スケールで割った単位)のあいだの変換を途中で挟んでいたので、表示スケールが100%でないと、移動量が合いませんでした。

直し方

始点も今の位置も、棚を載せているウィンドウから見た論理ピクセルで測るようにしました。同じ物差しで測った2点の差なので、変換そのものが要らなくなりました。その1週間後には、複数のモニターをまたいだときのずれも直しました。

DesktopOrganizer.UI/Controls/ShelfControl.xaml.cs(44行目)

_startPoint = e.GetPosition(null);

直したときのコミット:f2442a6(2025年12月29日)

要になったコード割合を、そのときの画面のピクセルに戻す

DesktopOrganizer.Core/Services/LayoutManager.cs(89〜92行目)

var x = (int)(waLeft + (shelf.X * waWidth));
var y = (int)(waTop + (shelf.Y * waHeight));
var w = (int)(shelf.Width * waWidth);
var h = (int)(shelf.Height * waHeight);

保存してあるのは、作業領域に対する割合(0〜1)だけです。表示するたびに、そのときの作業領域の大きさを掛けて、ピクセルに戻します。

解像度が変わっても、タスクバーの位置が変わっても、この4行が、そのときの画面での位置を計算し直します。

§02

SimpleZipper

PS> Get-Item .\SimpleZipper | Format-List *

ファイルやフォルダをZIPにまとめるWindowsのツール。失敗したときに、次に何をすればいいかが分かることにこだわりました。

SimpleZipperの画面。左にファイル一覧と圧縮オプション、右にログが出ている
右側のログに、何をしたかが時刻つきで残ります
7種類の失敗を見分けて案内
3日最初のコミットから公開まで
  • 2025.05
  • 個人開発
  • C# / WinForms / DotNetZip
SimpleZipperの画面と技術的な詳細を読む(設計・つまずき・コード)

1使っている画面

Z-2圧縮している途中の画面(進み具合と、処理中のファイル名が見えるもの)静止画 PNG → images/works/simplezipper/01-progress.png
圧縮は別のスレッドで進み、進み具合がバーで見えます。途中で中止もできます
Z-3失敗したときに出る案内。たとえば、ほかのアプリで開いているファイルを圧縮しようとしたとき静止画 PNG → images/works/simplezipper/02-error.png
失敗したら、理由と次にすることが右のログに出ます
Z-4圧縮が終わったときの画面(元のサイズ・圧縮後のサイズ・圧縮率が出ているもの)静止画 PNG → images/works/simplezipper/03-done.png
うまくいったら、できたZIPの名前が上に出ます

2作品の詳細

重い圧縮でも、画面を止めない

圧縮は別のスレッドで動かし、進み具合を画面に返します。いつでも中止できて、中止したことも結果として残ります。

日本語のファイル名を壊さない

新しく作るZIPは、ファイル名をShift_JISで書き込みます。Windowsの標準機能で開いたときに、日本語の名前が化けないようにするためです。大きなファイルは必要なときだけZip64にし、分割サイズは64KB未満や2GBを超える指定を自動で直します。

3こだわったこと

失敗したときに、次にすることが分かる

やりたかったこと

圧縮がうまくいかなかったとき、使う人が自分で次の手を打てること。

ぶつかった壁

ファイル操作の失敗は、種類がとても多いです。「エラーが発生しました」とだけ出しても、使う人は何もできません。

しかも、「空き容量が足りない」と「ほかのアプリがファイルを使っている」は、プログラムには同じ種類のエラー(IOException)として届きます。

どう作ったか

失敗を種類ごとに受け止めて、そのときに取れる行動を書きました。どのファイルを処理していたときの失敗かも添えます。

起きたこと画面に出す案内
ファイルが見つからないファイルが存在するか確認してください
フォルダが見つからないパスを確認してください
パスが長すぎる名前を短くするか、より浅い階層に移動してください
空き容量が足りない少なくとも(圧縮する元の合計サイズ)程度の空きを確保してください
ほかのアプリが使っている関連するプログラムを終了するか、しばらく待ってから再度お試しください
アクセスする権限がない管理者として実行するか、対象のアクセス権限を確認してください
ZIPそのものの問題パスワード・CRCの不一致・ZIP形式のサイズ上限など、原因の見当を一言添える

「空き容量が足りない」と「ほかのアプリが使っている」は、どちらも同じ IOException として届きます。そこで、例外の中に入っているWindowsのエラーコード(HRESULT)を取り出して見分けています。容量不足は 0x80070070 と 0x80070027、使用中は 0x80070020 と 0x80070021。

どうなったか

失敗しても、画面の案内を読めば次に何をすればいいかが分かります。うまくいったときだけでなく、失敗したときに迷わないことも品質だと考えています。

要になったコード同じ IOException を、中の番号で見分ける

Form1.cs(1761〜1775行目)

private bool IsDiskFull(System.IO.IOException ex)
{
    const int HR_ERROR_HANDLE_DISK_FULL = unchecked((int)0x80070027);
    const int HR_ERROR_DISK_FULL = unchecked((int)0x80070070);
    int hr = System.Runtime.InteropServices.Marshal.GetHRForException(ex);
    return hr == HR_ERROR_DISK_FULL || hr == HR_ERROR_HANDLE_DISK_FULL;
}

private bool IsSharingViolation(System.IO.IOException ex)
{
    int hr = System.Runtime.InteropServices.Marshal.GetHRForException(ex);
    const int HR_ERROR_SHARING_VIOLATION = unchecked((int)0x80070020);
    const int HR_ERROR_LOCK_VIOLATION = unchecked((int)0x80070021);
    return hr == HR_ERROR_SHARING_VIOLATION || hr == HR_ERROR_LOCK_VIOLATION;
}

空き容量が足りないときも、ほかのアプリがファイルを使っているときも、届くのは同じ IOException です。そこで、例外が持っている Windows のエラー番号(HRESULT)を取り出して比べます。

原因を見分けられるので、「空きを作ってください」と「そのアプリを閉じてください」を出し分けられます。

§03

GitPR Fluent

PS> Get-Item .\GitTooljp | Format-List *

Gitに慣れていない学生が、コミットからPR作成までを事故なく進めるためのGitクライアント。

GitPR Fluentの画面。変更ファイルの一覧、差分、Commit・Push・Pull・Create PRのボタンが並ぶ
左に変更したファイル、真ん中に差分、右に操作のボタン
5つの状態の組み合わせでボタンを制御
2日最初のコミットからv0.1.2.2まで
41コミット
  • 2025.12
  • 個人開発
  • C# / WPF / .NET 8 / git / gh

1使っている画面

変更の差分が、削除は赤、追加は緑で表示されている
変更の中身を、色分けした差分で確かめてからコミットします
Create Pull Requestの画面。タイトルと本文を入力する
PRのタイトルと本文を書けば、そのままGitHubに作られます
GitPR Fluentの技術的な詳細を読む(設計・つまずき・コード)
G-2他人のリポジトリのmainに、直接pushしようとしたときに出る警告静止画 PNG → images/works/gitprfluent/01-warning.png
他人のリポジトリのmainに直接pushしようとすると、止めます
G-3pullで競合したときに切り替わる、復旧の画面静止画 PNG → images/works/gitprfluent/02-rebase.png
競合したら復旧の画面に切り替わり、続けるか中止するかを選びます

2作品の詳細

他人のリポジトリのmainに、うっかり直接pushしない

pushしようとしているのがmainかmasterで、しかもリポジトリの持ち主が自分でなければ、いったん止めて警告を出し、続けるかやめるかを選ばせます。持ち主はリモートのURLから、自分はGitHub CLIのログイン情報から取ります。

Push ブランチは main か master? リポジトリの持ち主は自分? 警告を出す はい いいえ そのままpushする いいえ はい
警告では「続ける(このブランチでは今後警告しない)」「続ける(次も警告する)」「やめる」を選べます。

外のコマンドの出力を、正しく受け取る

両方の出力を同時に読む

標準出力と標準エラーを、イベントで並行して受け取ります。片方が詰まって、もう片方を待ち続けることがありません。

文字コードをUTF-8に揃える

呼び出すときの環境変数でGitの出力をUTF-8に固定し、日本語のファイル名やメッセージを化けさせません。ページャも切ります。

起動時に道具を確かめる

git・GitHub CLI・Visual Studio(vswhere経由)が使えるかを最初に調べ、足りなければ案内します。

3こだわったこと

押してはいけないボタンは、押せなくしておく

やりたかったこと

Gitに慣れていない学生でも、コミットからPRまで事故なく進めること。コマンドは覚えなくてもいいですが、何をしているかは見えます。「Gitを隠さないが、事故らせない」を目標にしました。

ぶつかった壁

Gitの事故の多くは、今の状態でやってはいけない操作をしてしまうことから起きます。変更が残ったままpullする、rebaseの途中で別の操作をする、といったこと。説明を読んでもらうだけでは防げません。

どう作ったか

リポジトリの状態を、5つの組み合わせで持ちます。どれも立っていなければ、きれいな状態。

状態意味
Dirtyコミットしていない変更がある
Unpushedpushしていないコミットがある
NoUpstreamリモートの追跡ブランチがまだない
Rebaserebaseの途中(競合している可能性がある)
AuthNgGitHub CLIのログインが必要

そして、ボタンを押せるかどうかを、この状態から決めます。押せる場面でも、危ない操作の前には確認を挟みます。

ボタン押せるとき押したときの確認
Commit変更があって、rebase中でないメッセージが空なら止める
Pushpushしていないコミットがあるか、追跡ブランチがない。rebase中でない他人のリポジトリのmainに直接pushしようとしたら警告する
Pull (Rebase)rebase中でない変更が残っていれば、先にコミットするか、stashするかを聞く
Create PRrebase中でないログインしていなければログインへ。pushしていなければ、先にpushするか聞く

pullは常に --rebase で行います。競合したときは復旧用の画面に切り替わり、Visual Studioで解決してから続きを実行できます。

どうなったか

押してはいけないボタンは、最初から押せません。いちばん上の画面では、変更はあるものの、まだコミットしておらず、pushするコミットがありません。だからPushのボタンは押せない状態になっています。

要になったコードPush を押せるかどうかを、1行で決める

ViewModels/MainViewModel.cs(1022行目)

private bool CanPush() => IsNotBusy && !IsRebaseMode && (CurrentState.HasFlag(RepoState.Unpushed) || CurrentState.HasFlag(RepoState.NoUpstream));

Push ボタンが押せるのは、作業中でなく、rebase の途中でもなく、送っていないコミットがあるか、まだ追跡ブランチがないときだけです。

ボタンは [RelayCommand(CanExecute = nameof(CanPush))] でこの条件とつながっています。押せない場面では、押す前から灰色になっています。