Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,7 @@ paths:
/api/v1/terminal/sessions/{id}/workspace:
put:
operationId: moveTerminalSession
description: Moves the session into the workspace, before beforeId or to the end of that workspace without it. The others keep their order. A beforeId that is not a session of that workspace is not found.
parameters:
- in: path
name: id
Expand Down Expand Up @@ -5882,12 +5883,15 @@ components:
additionalProperties: false
properties:
beforeId: { type: string, minLength: 1, maxLength: 64 }
# セッションを workspaceId のワークスペースへ移し、beforeId のセッションの前に置く。beforeId が
# 無ければ、そのワークスペースの最後に置く。並びはエンジンが持つので、どの画面から見ても同じになる。
MoveTerminalSessionRequest:
type: object
additionalProperties: false
required: [workspaceId]
properties:
workspaceId: { type: string, minLength: 1, maxLength: 64 }
beforeId: { type: string, minLength: 1, maxLength: 64 }
TerminalHostKeyQuestion:
type: object
additionalProperties: false
Expand Down
3 changes: 2 additions & 1 deletion docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@
- **ターミナルで範囲を選ぶと、選択を終えた時点でシステムのクリップボードへコピーします。** 右クリックは貼り付けです。どちらも既定は on で、設定画面 →「Terminal」から個別に止められ、開いているターミナルにもすぐ反映されます。右クリック、ブラウザの paste event、Ctrl+V / Cmd+V / Ctrl+Shift+V は同じ送信前検査を通ります。CR、LF、末尾の1改行、ESC/NUL/DEL等の制御文字を含む場合は、制御文字を可視化した有界preview、論理行数、接続先を表示し、まだ1 byteも送らない状態で「キャンセル」「貼り付け内容を編集」「末尾のEnterを除いて貼り付け」「貼り付け」を選ばせます。previewと原文は保存しません。確認中にプログラムが画面を描き直しても確認は閉じません。Claude Codeのような全画面のプログラムは、確認を開いてターミナルからフォーカスが外れただけで描き直すためです。セッションの終了、ターミナルとの接続の切断、再接続によるシェルの差し替え(engineが`{"shellReplaced":{}}`を送る)でだけ確認を取り消します。確認後にだけ改行をターミナル用のCRへ変換し、接続先が bracketed paste mode を有効にしていればwrapperを付けます。bracketed pasteは確認を省く安全条件にはしません。Cmd+C(macOS 以外は Ctrl+Shift+C)による明示コピーも残します。**素の Ctrl+C はそのまま SIGINT として向こうへ渡します。** xterm の選択はブラウザの選択ではないので、これらは画面側で処理します。
- 開いているセッションは左側のナビゲーションに表示します。デスクトップでは、上部に`Main`の区分(`Home`・`Connections`・`SFTP`)と`Menu`を置き、その下のスクロール領域に`Split views`(分割表示の一覧)と`Workspaces`(ワークスペースごとのセッションの木)を置きます。`Terminal`の項目は置かず、セッションを選ぶとターミナルの専用画面を開きます。ターミナルは接続先設定の隣ではなく、この専用画面に表示します。スマホでは同じナビゲーションをdrawerとして開き、下部ナビゲーションの5項目(`Home`・`Connections`・`SFTP`・`Terminal`・`Menu`)から直接移動できます。そのためdrawerの`Main`には`Home`だけを置き、`Connections`・`SFTP`・`Menu`を省いてセッションの一覧に高さを回します。`Home`は、下部ナビゲーションの代わりに上部のバーを出すターミナルの画面から、下部ナビゲーションへ戻るために残します。drawer内の操作行も原則44px以上のタッチ対象を確保します。ただし`Workspaces`の木(見出し、ワークスペースの行、セッションの行と、行の中のボタン)は、一度に多くのセッションを見せるため36pxにします(`data-touch-dense`)。
- セッションの一覧は状態別に分けません。各項目は 2 行で、1 行目に名前、2 行目に「状態 · 接続先」を表示します。種類(SSH / シェル)は、接続先が `localhost` かどうかで判別できるため表示しません。終了した項目も元の位置に残し、接続失敗の詳細を見つけやすくします。
- 各項目の `···` メニューから、名前変更、同じ接続先への新規接続、上下移動を実行できます。ドラッグによる並べ替えに加え、キーボード操作を可能にするためメニューにも上下移動を用意します。名前と並び順はセッション固有であり、`metadata.json` には保存しません。
- 各項目の `···` メニューから、名前変更、同じ接続先への新規接続、上下移動を実行できます。ドラッグによる並べ替えに加え、キーボード操作を可能にするためメニューにも上下移動を用意します。名前と並び順はsshcエンジンがセッションと一緒にメモリに持ち、`metadata.json` には保存しません。どのブラウザやマシンから開いても同じ名前と順で並びます。
- 右側のインスペクタは`Connections`と`SSH Keys`で使用します。Connectionsでは`~/.ssh/config`に保存するグループ、コメント、aliasを主画面に置き、`metadata.json`にだけ保存する色、タグ、表示順と、警告・継承元をインスペクタに置きます。グループは、選ぶと右側に編集欄を出し、フォルダの中身(`connections/<グループ>/`のファイルと接続先、`keys/<グループ>/`の鍵、`~/.ssh/config`で読み込む`Include`行)、接続先の既定値、色を置きます。子グループの作成、改名、上へ移動・下へ移動(表示順)、Connectionsで隠す、削除は、グループの「…」に置きます。SSH Keysでは、選択した鍵のフィンガープリント、ファイルの権限、参照している接続をインスペクタに置きます。行を選択すると対象をインスペクタに表示します。それ以外の画面ではインスペクタを使用しないため、開閉ボタンも表示しません。
- インスペクタは既定で閉じており、開閉状態はセクションをまたいで保持します。中身に注意がある時だけ開閉ボタンに琥珀の印が付き、その印は読み上げにも「確認が必要な項目があります」として届きます。印が無ければ開ける価値が無いことを意味します。
- インスペクタにキーボードショートカットはありません。macOS の慣習は ⌥⌘I ですが、⌥⌘I は Chromium が開発者ツールとして先に取ります。
Expand Down Expand Up @@ -471,6 +471,7 @@ VPNプロファイルを付けた接続は、プロファイルごとのDocker
- putとremote間copyは、転送先の隣に一時ファイルを書いてからrenameで公開します。一時停止や取り消しで止めた場合、元の接続はcontextの取り消しで閉じているので、新しい接続を開いて一時ファイルを消します(30秒で諦めます)。回線が切れた場合や、one-time codeで2本目の接続を開けない接続先では消せずに残ることがあります。一時ファイルは一覧に出ないので、1日以上書き込みのない一時ファイルは残骸とみなし、フォルダの削除や別の接続先へのmoveで一緒に消します。転送元の更新日時は公開したあとで転送先に付けるので、書き込み中の一時ファイルの更新日時は最後に書いた時刻のままです。書き込み中の一時ファイルがあるフォルダは、削除もmoveもしません。
- server が返す entry 名は信用しません。空、`.`、`..`、`/` や NUL を含む名前を返す listing は、一覧・copy・delete・archive のすべてで `invalid_path` として失敗させ、黙って飛ばしません。engine の local file system へ書く download と archive は `\` も拒否します。revision は接頭辞で種類を区別し、`content-sha256:` は editor・download・upload のどれで得ても同じ内容なら一致します。
- ターミナルのワークスペースは、セッションを名前の付いたまとまりに分けるものです(`internal/terminalworkspace`)。名前と表示するかと並びだけを`~/.ssh/sshc/workspaces.json`(schema 3)に保存し、どのセッションがどこに入っているかは、セッションと同じくsshcエンジンのメモリだけに持ちます。消せないワークスペースは2つで、画面から開いたセッションが入る`default`と、`sshc terminal create`で開いたセッションが既定で入る非表示の`agents`です。LLMやスクリプトが開いた接続をサイドバーに混ぜないためです。CLIは`--workspace`で入れ先を指定でき、sshcエンジンは要求の`workspaceId`に従うだけで、誰が開いたかは見ません。ワークスペースを消すと、中のセッションは閉じずに`default`へ移します。ファイルの配列の順がそのまま画面の並びで、`PUT /api/v1/terminal/workspaces/{id}/position`が1つを`beforeId`の前(無ければ末尾)へ移します。並び全体を送る形にしないのは、別のタブで作られたばかりのワークスペースを知らない画面からの移動でも、その場所を崩さないためです。新しいワークスペースは末尾に、末尾が`agents`ならその前に入れ、普段は隠す`agents`を利用者が動かさない限り一番下に保ちます。schema 3より前のファイルは読まずに無いものとして扱い、次の保存で上書きします。
- ワークスペースの中のセッションの順は、sshcエンジンのセッションの一覧の順(`terminal.Registry`)です。一覧には開いた順に足し、`PUT /api/v1/terminal/sessions/{id}/workspace`が1つを`workspaceId`のワークスペースへ移して、`beforeId`のセッションの前に、無ければ一覧の末尾(そのワークスペースの最後)に置きます。同じワークスペースの中での並べ替えも同じ要求です。並び全体を送る形にしないのは、ワークスペースの並べ替えと同じく、別の画面が開いたばかりのセッションの場所を崩さないためです。`beforeId`のセッションが無いか、別のワークスペースにあれば`404 terminal_session_not_found`で断り、何も変えません。画面は答えを待たずに移したあとの順で描き(リモートセッションでは答えがSSH越しに届くため)、断られたら一覧を読み直します。順はメモリだけに持ち、開き直すための記録(`terminal-sessions.json`)にこの順で書きます。一覧に残す終了済みのセッションは、位置ではなく終わった時刻の新しい方から選びます。
- 分割表示は、セッションを最大4ペインに並べる画面の並べ方です(`web/src/terminal/splitView/`)。分割は最大8つまで持て、サイドバーの`Split views`から切り替えます。セッションが入れる分割は1つだけで、ワークスペースの所属とは別です。分割と配置はサーバーにもブラウザにも保存せず、タブの再読み込みやエンジンの切り替えで解除します。
- Terminal内検索はxtermのメモリ上のscrollbackを走査し、大文字小文字、正規表現、全一致highlightを切り替えて前後の一致を選択表示します。検索barはTerminalへ重ね、開閉時にPTYの行数や描画位置を変えません。sshc独自のcommand履歴・入力候補・remote path補完は持たず、shellや接続先アプリケーションの補完をそのまま利用します。
- Snippet library と startup binding は `~/.ssh/sshc/snippets.json` に保存しますが、ディスク上の文書全体をローカルの master key で暗号化します。平文の文書は読みません。remote sync ではマシン固有の暗号文を運ばず、検証済みの論理文書を snapshot 全体の暗号化内へ載せ、受信側のマシンの master key で再暗号化します。
Expand Down
1 change: 1 addition & 0 deletions docs/manual-acceptance.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ OpenSSHコンテナに対するプロトコル往復は`make integration`で自
6. 通常変数を持つSnippetは展開後commandをpreviewして送信でき、secret変数を持つSnippetは送信できないことを確認する。
7. セッションの[…]→[ワークスペースへ移動]で別のワークスペースへ移せること、ワークスペースを削除すると中のセッションが閉じずに「メイン」へ移ることを確認する。
8. ワークスペースの行を別のワークスペースの上半分・下半分へドラッグし、その前・後ろへ並ぶこと、[…]→[上へ移動][下へ移動]でも動くこと、ページを読み込み直しても並びが残ることを確認する。
9. セッションの行を同じワークスペースの別のセッションの上へドラッグし、その前へ並ぶこと、セッションの[…]→[上へ移動][下へ移動]でも動くことを確認する。別のブラウザで同じsshcエンジン(リモートセッションなら同じ接続先)を開き、同じ順に並ぶこと、ページを読み込み直しても順が残ることを確認する。

## M10. Terminal検索と入力

Expand Down
3 changes: 2 additions & 1 deletion internal/api/models.gen.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 7 additions & 1 deletion internal/httpserver/terminal_workspaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,8 @@ func (h TerminalHandlers) MoveWorkspace(c *echo.Context) error {
return c.JSON(http.StatusOK, list)
}

// MoveSession は、セッションを別のワークスペースへ移し、beforeId のセッションの前に、無ければそのワークスペースの
// 最後に置いて、新しい一覧を返す。同じワークスペースの中での並べ替えも、これで行う。
func (h TerminalHandlers) MoveSession(c *echo.Context) error {
id, ok := sessionIDParam(c)
if !ok {
Expand All @@ -155,7 +157,11 @@ func (h TerminalHandlers) MoveSession(c *echo.Context) error {
if err := h.requireWorkspace(request.WorkspaceId); err != nil {
return terminalWorkspaceProblem(c, err)
}
if err := h.Registry.MoveToWorkspace(id, request.WorkspaceId); err != nil {
beforeID := ""
if request.BeforeId != nil {
beforeID = *request.BeforeId
}
if err := h.Registry.MoveToWorkspace(id, request.WorkspaceId, beforeID); err != nil {
if errors.Is(err, terminal.ErrNotFound) {
return problem(c, http.StatusNotFound, "terminal_session_not_found")
}
Expand Down
68 changes: 68 additions & 0 deletions internal/httpserver/terminal_workspaces_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,3 +143,71 @@ func TestAMovedWorkspaceIsListedInItsNewPlace(t *testing.T) {
t.Fatalf("listed order = %v, want %v", got, want)
}
}

func (f *terminalFixture) sessionOrder(t *testing.T) []string {
t.Helper()
_, body := f.do(t, http.MethodGet, "/api/v1/terminal/sessions", "")
return sessionIDs(decodeAs[api.TerminalSessionList](t, body))
}

func sessionIDs(list api.TerminalSessionList) []string {
ids := make([]string, 0, len(list.Sessions))
for _, session := range list.Sessions {
ids = append(ids, session.Id)
}
return ids
}

// 並びはエンジンが持つので、移した画面の答えにも、あとから一覧を読む別の画面にも同じ順で出る。
func TestAMovedSessionIsListedInItsNewPlaceForEveryScreen(t *testing.T) {
fixture := newTerminalFixture(t, terminal.DefaultLimits())
first, _ := fixture.openShell(t)
second, _ := fixture.openShell(t)
third, _ := fixture.openShell(t)

response, body := fixture.do(t, http.MethodPut, "/api/v1/terminal/sessions/"+third+"/workspace", `{"workspaceId":"default","beforeId":"`+first+`"}`)
if response.StatusCode != http.StatusOK {
t.Fatalf("move before the first = %d %s", response.StatusCode, body)
}
want := []string{third, first, second}
if got := sessionIDs(decodeAs[api.TerminalSessionList](t, body)); !slices.Equal(got, want) {
t.Fatalf("answered order = %v, want %v", got, want)
}
if got := fixture.sessionOrder(t); !slices.Equal(got, want) {
t.Fatalf("listed order = %v, want %v", got, want)
}

response, body = fixture.do(t, http.MethodPut, "/api/v1/terminal/sessions/"+third+"/workspace", `{"workspaceId":"default"}`)
if response.StatusCode != http.StatusOK {
t.Fatalf("move to the end = %d %s", response.StatusCode, body)
}
if got, want := fixture.sessionOrder(t), []string{first, second, third}; !slices.Equal(got, want) {
t.Fatalf("order after moving to the end = %v, want %v", got, want)
}
}

// 前に置くセッションが無い(別の画面が閉じた)か、別のワークスペースにある(別の画面が移した)ときは断り、
// 並びもワークスペースも変えない。画面は一覧を読み直して今の並びを出す。
func TestAMoveBeforeASessionThatIsNotInTheWorkspaceIsRefusedAndChangesNothing(t *testing.T) {
fixture := newTerminalFixture(t, terminal.DefaultLimits())
first, _ := fixture.openShell(t)
second, _ := fixture.openShell(t)
response, body := fixture.do(t, http.MethodPost, "/api/v1/terminal/workspaces", `{"name":"sshc開発"}`)
if response.StatusCode != http.StatusCreated {
t.Fatalf("create = %d %s", response.StatusCode, body)
}
created := decodeAs[api.TerminalWorkspace](t, body)

for _, beforeID := range []string{"missing", first} {
response, body := fixture.do(t, http.MethodPut, "/api/v1/terminal/sessions/"+second+"/workspace", `{"workspaceId":"`+created.Id+`","beforeId":"`+beforeID+`"}`)
if response.StatusCode != http.StatusNotFound || decodeAs[api.Problem](t, body).Code != "terminal_session_not_found" {
t.Errorf("move before %s = %d %s, want 404 terminal_session_not_found", beforeID, response.StatusCode, body)
}
}
if got, want := fixture.sessionOrder(t), []string{first, second}; !slices.Equal(got, want) {
t.Fatalf("order after the refusals = %v, want %v", got, want)
}
if got := fixture.sessionWorkspace(t, second); got != terminalworkspace.DefaultID {
t.Fatalf("workspace after the refusals = %q, want default", got)
}
}
2 changes: 1 addition & 1 deletion internal/remoteengine/remote_engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ var (
// ストリームの形を、古い相手と通じない形に変えたときに 1 つ上げる。
// 上げたリリースでは、実行先の sshc も更新が要る(docs/releasing.md)。
// 0 は、番号を書かない古い sshc の handoff を読んだときの値なので使わない。
const Compatibility = 2
const Compatibility = 3

// IncompatibleEngineError は、接続先の sshc の互換の番号がこのマシンと違うことを表す。
// 番号を書かない古い sshc も含む。Local と Remote は、それぞれの sshc のバージョンである。
Expand Down
Loading
Loading