hop2 0.11.1 → 0.11.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yml +6 -2
- package/hay/apps/server/src/rooms.ts +7 -0
- package/hay/apps/server/test/rooms.test.ts +21 -0
- package/hay/apps/web/src/App.tsx +113 -20
- package/hay/apps/web/src/components/ContextMenu.tsx +109 -0
- package/hay/apps/web/src/components/SessionSwitcher.tsx +289 -52
- package/hay/apps/web/src/styles.css +218 -41
- package/hay/apps/web/src/utils/switcherModel.ts +41 -4
- package/hay/apps/web/test/SessionSwitcher.test.tsx +51 -0
- package/hay/apps/web/test/switcherModel.test.ts +65 -0
- package/hay-web/assets/{index--cf1ILsi.css → index-BohAWpcL.css} +1 -1
- package/hay-web/assets/index-DfGT3DuO.js +130 -0
- package/hay-web/assets/index-DfGT3DuO.js.map +1 -0
- package/hay-web/index.html +2 -2
- package/hop +149 -12
- package/package.json +1 -1
- package/scripts/hay-host.js +3 -1
- package/hay-web/assets/index-_wMnETjm.js +0 -130
- package/hay-web/assets/index-_wMnETjm.js.map +0 -1
|
@@ -2895,52 +2895,24 @@ button.primary {
|
|
|
2895
2895
|
}
|
|
2896
2896
|
|
|
2897
2897
|
/* Tap-a-URL prompt (mobile: remote apps can't open the phone's browser) */
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
bottom: calc(env(safe-area-inset-bottom, 0px) + var(--mobile-keyboard-height, 0px) + 14px);
|
|
2903
|
-
z-index: 650;
|
|
2904
|
-
display: flex;
|
|
2905
|
-
align-items: center;
|
|
2906
|
-
gap: 8px;
|
|
2907
|
-
padding: 10px 12px;
|
|
2908
|
-
background: var(--panel);
|
|
2909
|
-
border: 1px solid var(--accent);
|
|
2910
|
-
border-radius: 12px;
|
|
2911
|
-
box-shadow: var(--shadow);
|
|
2898
|
+
/* Link menu: the terminal's tap-a-URL menu, on the shared context-menu look.
|
|
2899
|
+
Anchored at the link like every other menu, not docked to the viewport. */
|
|
2900
|
+
.context-menu.link-menu {
|
|
2901
|
+
max-width: min(340px, calc(100vw - 16px));
|
|
2912
2902
|
}
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
|
|
2903
|
+
.context-menu-title {
|
|
2904
|
+
display: block;
|
|
2905
|
+
padding: 6px 10px 7px;
|
|
2906
|
+
font-family: var(--font-mono);
|
|
2907
|
+
font-size: 11.5px;
|
|
2908
|
+
color: var(--ink-muted);
|
|
2917
2909
|
overflow: hidden;
|
|
2918
2910
|
text-overflow: ellipsis;
|
|
2919
2911
|
white-space: nowrap;
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
color: var(--ink);
|
|
2923
|
-
}
|
|
2924
|
-
|
|
2925
|
-
.link-prompt button {
|
|
2926
|
-
flex-shrink: 0;
|
|
2927
|
-
padding: 7px 12px;
|
|
2928
|
-
font-size: 12.5px;
|
|
2929
|
-
}
|
|
2930
|
-
|
|
2931
|
-
.link-prompt .link-prompt-open {
|
|
2932
|
-
background: var(--accent);
|
|
2933
|
-
color: var(--accent-fg);
|
|
2912
|
+
border-bottom: 1px solid var(--line);
|
|
2913
|
+
margin-bottom: 3px;
|
|
2934
2914
|
}
|
|
2935
2915
|
|
|
2936
|
-
@media (min-width: 769px) {
|
|
2937
|
-
.link-prompt {
|
|
2938
|
-
left: auto;
|
|
2939
|
-
right: 20px;
|
|
2940
|
-
bottom: 44px;
|
|
2941
|
-
max-width: 520px;
|
|
2942
|
-
}
|
|
2943
|
-
}
|
|
2944
2916
|
|
|
2945
2917
|
/* ── Tile-zoom control (− level +): bigger tiles show more terminal. The
|
|
2946
2918
|
ladder itself (widths, heights, font sizes, how many levels this viewport
|
|
@@ -3691,9 +3663,17 @@ button.primary {
|
|
|
3691
3663
|
scrollbar wide), and entering full screen (which has no such reservation)
|
|
3692
3664
|
visibly re-flowed. Hidden bar → width-true fits on both surfaces. */
|
|
3693
3665
|
.switcher-live-tile .xterm-viewport {
|
|
3694
|
-
|
|
3666
|
+
/* A WATCH tile is a snapshot of the live screen — there is nothing to
|
|
3667
|
+
scroll through, and letting it scroll produced a bar that moved the view
|
|
3668
|
+
off the newest output with no way to tell you were adrift. Only the tile
|
|
3669
|
+
you have clicked into (is-live) scrolls, because only that one has a
|
|
3670
|
+
real session's scrollback behind it. */
|
|
3671
|
+
overflow: hidden !important;
|
|
3695
3672
|
scrollbar-width: none;
|
|
3696
3673
|
}
|
|
3674
|
+
.switcher-live-tile.is-live .xterm-viewport {
|
|
3675
|
+
overflow-y: auto !important;
|
|
3676
|
+
}
|
|
3697
3677
|
.switcher-live-tile .xterm-viewport::-webkit-scrollbar {
|
|
3698
3678
|
width: 0;
|
|
3699
3679
|
height: 0;
|
|
@@ -3715,3 +3695,200 @@ button.primary {
|
|
|
3715
3695
|
opacity: 0.72;
|
|
3716
3696
|
border-style: dashed;
|
|
3717
3697
|
}
|
|
3698
|
+
|
|
3699
|
+
/* Manual-mode folders: user-authored grouping, distinct from Project mode's
|
|
3700
|
+
automatic directory sections. */
|
|
3701
|
+
.switcher-folder-new {
|
|
3702
|
+
margin-left: 10px;
|
|
3703
|
+
padding: 1px 8px;
|
|
3704
|
+
border-radius: 999px;
|
|
3705
|
+
border: 1px dashed var(--line);
|
|
3706
|
+
background: transparent;
|
|
3707
|
+
color: var(--ink-muted);
|
|
3708
|
+
font-family: var(--font-mono);
|
|
3709
|
+
font-size: 11px;
|
|
3710
|
+
cursor: pointer;
|
|
3711
|
+
}
|
|
3712
|
+
.switcher-folder-new:hover {
|
|
3713
|
+
border-color: var(--accent);
|
|
3714
|
+
color: var(--ink);
|
|
3715
|
+
}
|
|
3716
|
+
.switcher-folder {
|
|
3717
|
+
border: 1px solid transparent;
|
|
3718
|
+
border-radius: 12px;
|
|
3719
|
+
padding: 2px 6px 6px;
|
|
3720
|
+
transition: border-color 0.12s ease, background 0.12s ease;
|
|
3721
|
+
}
|
|
3722
|
+
.switcher-folder.drop-target,
|
|
3723
|
+
.switcher-unfiled.drop-target {
|
|
3724
|
+
border-color: var(--accent);
|
|
3725
|
+
background: color-mix(in srgb, var(--accent) 7%, transparent);
|
|
3726
|
+
}
|
|
3727
|
+
.switcher-folder-count {
|
|
3728
|
+
margin-left: 8px;
|
|
3729
|
+
font-size: 10.5px;
|
|
3730
|
+
color: var(--ink-muted);
|
|
3731
|
+
}
|
|
3732
|
+
.switcher-folder .switcher-group-label button,
|
|
3733
|
+
.switcher-unfiled button {
|
|
3734
|
+
margin-left: 2px;
|
|
3735
|
+
border: none;
|
|
3736
|
+
background: transparent;
|
|
3737
|
+
color: var(--ink-muted);
|
|
3738
|
+
cursor: pointer;
|
|
3739
|
+
font-size: 10.5px;
|
|
3740
|
+
line-height: 1;
|
|
3741
|
+
padding: 2px 4px;
|
|
3742
|
+
border-radius: 4px;
|
|
3743
|
+
opacity: 0;
|
|
3744
|
+
transition: opacity 0.12s ease;
|
|
3745
|
+
}
|
|
3746
|
+
.switcher-folder .switcher-group-label button:hover {
|
|
3747
|
+
color: var(--ink);
|
|
3748
|
+
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
|
3749
|
+
}
|
|
3750
|
+
/* The + CREATES — primary affordance, always visible; edit/delete are
|
|
3751
|
+
maintenance and stay hover-revealed. */
|
|
3752
|
+
.switcher-folder .switcher-group-label .switcher-folder-add {
|
|
3753
|
+
opacity: 1;
|
|
3754
|
+
font-size: 12.5px;
|
|
3755
|
+
margin-left: 6px;
|
|
3756
|
+
}
|
|
3757
|
+
.switcher-folder:hover .switcher-group-label button {
|
|
3758
|
+
opacity: 1;
|
|
3759
|
+
}
|
|
3760
|
+
.switcher-folder-empty {
|
|
3761
|
+
margin: 4px 0 8px;
|
|
3762
|
+
font-family: var(--font-mono);
|
|
3763
|
+
font-size: 11.5px;
|
|
3764
|
+
color: var(--ink-muted);
|
|
3765
|
+
}
|
|
3766
|
+
.switcher-unfiled {
|
|
3767
|
+
border: 1px solid transparent;
|
|
3768
|
+
border-radius: 10px;
|
|
3769
|
+
padding: 2px 6px;
|
|
3770
|
+
}
|
|
3771
|
+
|
|
3772
|
+
/* Context menus. One look everywhere: same panel, line, and radius as the
|
|
3773
|
+
session sheet, so a right-click never feels like a different application. */
|
|
3774
|
+
.context-menu {
|
|
3775
|
+
position: fixed;
|
|
3776
|
+
z-index: 120;
|
|
3777
|
+
min-width: 190px;
|
|
3778
|
+
padding: 5px;
|
|
3779
|
+
border-radius: 11px;
|
|
3780
|
+
border: 1px solid var(--line);
|
|
3781
|
+
background: var(--panel);
|
|
3782
|
+
box-shadow: 0 10px 34px rgba(0, 0, 0, 0.3);
|
|
3783
|
+
display: flex;
|
|
3784
|
+
flex-direction: column;
|
|
3785
|
+
gap: 1px;
|
|
3786
|
+
}
|
|
3787
|
+
.context-menu-item {
|
|
3788
|
+
display: flex;
|
|
3789
|
+
align-items: center;
|
|
3790
|
+
justify-content: space-between;
|
|
3791
|
+
gap: 12px;
|
|
3792
|
+
width: 100%;
|
|
3793
|
+
padding: 7px 10px;
|
|
3794
|
+
border: none;
|
|
3795
|
+
border-radius: 7px;
|
|
3796
|
+
background: transparent;
|
|
3797
|
+
color: var(--ink);
|
|
3798
|
+
font-family: var(--font-ui, inherit);
|
|
3799
|
+
font-size: 13px;
|
|
3800
|
+
text-align: left;
|
|
3801
|
+
cursor: pointer;
|
|
3802
|
+
}
|
|
3803
|
+
.context-menu-item.active:not(:disabled) {
|
|
3804
|
+
background: color-mix(in srgb, var(--accent) 14%, transparent);
|
|
3805
|
+
}
|
|
3806
|
+
.context-menu-item:disabled {
|
|
3807
|
+
color: var(--ink-muted);
|
|
3808
|
+
cursor: default;
|
|
3809
|
+
}
|
|
3810
|
+
.context-menu-item.danger {
|
|
3811
|
+
color: #e5484d;
|
|
3812
|
+
}
|
|
3813
|
+
.context-menu-item.danger.active {
|
|
3814
|
+
background: color-mix(in srgb, #e5484d 14%, transparent);
|
|
3815
|
+
}
|
|
3816
|
+
.context-menu-item kbd {
|
|
3817
|
+
font-family: var(--font-mono);
|
|
3818
|
+
font-size: 11px;
|
|
3819
|
+
color: var(--ink-muted);
|
|
3820
|
+
}
|
|
3821
|
+
.context-menu-separator {
|
|
3822
|
+
height: 1px;
|
|
3823
|
+
margin: 4px 6px;
|
|
3824
|
+
background: var(--line);
|
|
3825
|
+
}
|
|
3826
|
+
|
|
3827
|
+
|
|
3828
|
+
/* ── Mobile switcher: a phone toolbar, not a desktop toolbar squeezed. The
|
|
3829
|
+
top bar had NO narrow-screen rules — ten header items and seven tab
|
|
3830
|
+
buttons on two nowrap rows shoved the right-edge actions (keyboard!)
|
|
3831
|
+
against the bezel and overflowed the tab row. ── */
|
|
3832
|
+
@media (max-width: 767px) {
|
|
3833
|
+
.switcher-header {
|
|
3834
|
+
gap: 6px;
|
|
3835
|
+
padding: calc(env(safe-area-inset-top, 0px) + 8px)
|
|
3836
|
+
calc(env(safe-area-inset-right, 0px) + 10px)
|
|
3837
|
+
8px
|
|
3838
|
+
calc(env(safe-area-inset-left, 0px) + 10px);
|
|
3839
|
+
}
|
|
3840
|
+
.switcher-header h2 {
|
|
3841
|
+
font-size: 15px;
|
|
3842
|
+
}
|
|
3843
|
+
/* + alone — the word is desktop chrome. */
|
|
3844
|
+
.switcher-new-label {
|
|
3845
|
+
display: none;
|
|
3846
|
+
}
|
|
3847
|
+
/* Explained by a tooltip, and touch has no tooltips: the chip is noise
|
|
3848
|
+
here. Interactivity is discovered by tapping a tile, which is the
|
|
3849
|
+
gesture anyway. */
|
|
3850
|
+
.switcher-chip.live {
|
|
3851
|
+
display: none;
|
|
3852
|
+
}
|
|
3853
|
+
.switcher-zoom-readout {
|
|
3854
|
+
display: none;
|
|
3855
|
+
}
|
|
3856
|
+
.switcher-tilesize button {
|
|
3857
|
+
padding: 5px 10px; /* finger-sized, was mouse-sized */
|
|
3858
|
+
}
|
|
3859
|
+
/* Real tap targets for the header actions, clear of the screen edge
|
|
3860
|
+
(the ✕ keeps them off the bezel on notched phones via header padding). */
|
|
3861
|
+
.switcher-action,
|
|
3862
|
+
.switcher-close {
|
|
3863
|
+
width: 38px;
|
|
3864
|
+
height: 38px;
|
|
3865
|
+
}
|
|
3866
|
+
/* Tabs wrap onto tidy lines; the filter takes a full row of its own —
|
|
3867
|
+
a text input sharing a squeezed line with seven buttons helped nobody. */
|
|
3868
|
+
.switcher-filter-row {
|
|
3869
|
+
flex-wrap: wrap;
|
|
3870
|
+
row-gap: 8px;
|
|
3871
|
+
padding: 0 calc(env(safe-area-inset-right, 0px) + 10px) 10px
|
|
3872
|
+
calc(env(safe-area-inset-left, 0px) + 10px);
|
|
3873
|
+
}
|
|
3874
|
+
.switcher-filter,
|
|
3875
|
+
.switcher-filter.compact {
|
|
3876
|
+
flex: 1 1 100%;
|
|
3877
|
+
order: 2;
|
|
3878
|
+
opacity: 1;
|
|
3879
|
+
min-height: 38px;
|
|
3880
|
+
}
|
|
3881
|
+
.switcher-origin button {
|
|
3882
|
+
padding: 6px 10px;
|
|
3883
|
+
font-size: 11px;
|
|
3884
|
+
}
|
|
3885
|
+
}
|
|
3886
|
+
|
|
3887
|
+
/* Touch devices: hover-revealed controls do not exist. Folder rename/delete
|
|
3888
|
+
were literally unreachable on a phone. */
|
|
3889
|
+
@media (hover: none) {
|
|
3890
|
+
.switcher-folder .switcher-group-label button,
|
|
3891
|
+
.switcher-unfiled button {
|
|
3892
|
+
opacity: 1;
|
|
3893
|
+
}
|
|
3894
|
+
}
|
|
@@ -35,6 +35,8 @@ export type SwitcherSession = {
|
|
|
35
35
|
parked?: boolean;
|
|
36
36
|
// Archived: process stopped, but reopening resumes the conversation.
|
|
37
37
|
archived?: boolean;
|
|
38
|
+
// Folder the user filed this session under (Manual mode). Null = loose.
|
|
39
|
+
folderId?: string | null;
|
|
38
40
|
// A hop CLI is attached in a real terminal window on the host. The wall
|
|
39
41
|
// must never resize such a session — that window's size is physical truth.
|
|
40
42
|
hasLocalCli?: boolean;
|
|
@@ -53,11 +55,13 @@ export type SwitcherGroup = {
|
|
|
53
55
|
rows: SwitcherSession[];
|
|
54
56
|
};
|
|
55
57
|
|
|
58
|
+
export type SwitcherFolder = { id: string; name: string };
|
|
59
|
+
|
|
56
60
|
export type SwitcherModel =
|
|
57
61
|
| { mode: "tiers"; hero: SwitcherSession[]; groups: SwitcherGroup[]; currentInHero: boolean }
|
|
58
62
|
| { mode: "filter"; rows: SwitcherSession[] }
|
|
59
63
|
| { mode: "project"; groups: SwitcherGroup[] }
|
|
60
|
-
| { mode: "manual"; rows: SwitcherSession[] };
|
|
64
|
+
| { mode: "manual"; rows: SwitcherSession[]; folders: Array<{ folder: SwitcherFolder; rows: SwitcherSession[] }> };
|
|
61
65
|
|
|
62
66
|
// Minimum hero-card count (including the current session). All attention
|
|
63
67
|
// sessions are always hero cards — attention must never be buried in the tail.
|
|
@@ -188,12 +192,41 @@ const orderManually = (sessions: SwitcherSession[], manualOrder: string[]): Swit
|
|
|
188
192
|
});
|
|
189
193
|
};
|
|
190
194
|
|
|
195
|
+
/**
|
|
196
|
+
* Split sessions into the user's folders plus everything still loose.
|
|
197
|
+
* Folders belong to MANUAL mode only: Recent is ordered by the machine and
|
|
198
|
+
* Project by the filesystem, so a hand-authored grouping would contradict
|
|
199
|
+
* both. Empty folders are kept — a folder you made is a place you intend to
|
|
200
|
+
* put things, and silently hiding it would make new folders look broken.
|
|
201
|
+
*/
|
|
202
|
+
const splitIntoFolders = (
|
|
203
|
+
sessions: SwitcherSession[],
|
|
204
|
+
folders: SwitcherFolder[],
|
|
205
|
+
manualOrder: string[]
|
|
206
|
+
) => {
|
|
207
|
+
const byId = new Map(folders.map((f) => [f.id, [] as SwitcherSession[]]));
|
|
208
|
+
const loose: SwitcherSession[] = [];
|
|
209
|
+
for (const s of sessions) {
|
|
210
|
+
const bucket = s.folderId ? byId.get(s.folderId) : undefined;
|
|
211
|
+
if (bucket) bucket.push(s);
|
|
212
|
+
else loose.push(s);
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
folders: folders.map((folder) => ({
|
|
216
|
+
folder,
|
|
217
|
+
rows: orderManually(byId.get(folder.id) || [], manualOrder)
|
|
218
|
+
})),
|
|
219
|
+
loose: orderManually(loose, manualOrder)
|
|
220
|
+
};
|
|
221
|
+
};
|
|
222
|
+
|
|
191
223
|
export const buildSwitcherModel = (
|
|
192
224
|
sessions: SwitcherSession[],
|
|
193
225
|
currentRoom: string | null,
|
|
194
226
|
filter: string,
|
|
195
227
|
sortMode: SwitcherSortMode = "recent",
|
|
196
|
-
manualOrder: string[] = []
|
|
228
|
+
manualOrder: string[] = [],
|
|
229
|
+
folders: SwitcherFolder[] = []
|
|
197
230
|
): SwitcherModel => {
|
|
198
231
|
const query = filter.trim();
|
|
199
232
|
if (query) {
|
|
@@ -207,7 +240,10 @@ export const buildSwitcherModel = (
|
|
|
207
240
|
// are hidden. Score-ranking here would reorder the wall under the user's
|
|
208
241
|
// hands at the exact moment they were trying to arrange it.
|
|
209
242
|
if (sortMode === "manual") {
|
|
210
|
-
|
|
243
|
+
// Filtering narrows within folders too, so a search never pretends a
|
|
244
|
+
// session left the folder you filed it in.
|
|
245
|
+
const split = splitIntoFolders(matches, folders, manualOrder);
|
|
246
|
+
return { mode: "manual", rows: split.loose, folders: split.folders };
|
|
211
247
|
}
|
|
212
248
|
const rows = matches
|
|
213
249
|
.map((s) => ({ s, score: filterScore(s, q) }))
|
|
@@ -221,7 +257,8 @@ export const buildSwitcherModel = (
|
|
|
221
257
|
return { mode: "project", groups: groupByProject(sessions, true) };
|
|
222
258
|
}
|
|
223
259
|
if (sortMode === "manual") {
|
|
224
|
-
|
|
260
|
+
const split = splitIntoFolders(sessions, folders, manualOrder);
|
|
261
|
+
return { mode: "manual", rows: split.loose, folders: split.folders };
|
|
225
262
|
}
|
|
226
263
|
|
|
227
264
|
const current = sessions.find((s) => isCurrent(s, currentRoom)) ?? null;
|
|
@@ -271,3 +271,54 @@ describe("focus rules", () => {
|
|
|
271
271
|
expect(document.querySelectorAll(".switcher-live-tile.is-live").length).toBe(0);
|
|
272
272
|
});
|
|
273
273
|
});
|
|
274
|
+
|
|
275
|
+
describe("context menus", () => {
|
|
276
|
+
afterEach(() => { document.body.innerHTML = ""; });
|
|
277
|
+
|
|
278
|
+
it("right-clicking the wall offers create + view actions, not session actions", () => {
|
|
279
|
+
render(<SessionSwitcher {...props} open />);
|
|
280
|
+
const scroll = document.querySelector(".switcher-scroll")!;
|
|
281
|
+
fireEvent.contextMenu(scroll, { clientX: 40, clientY: 40 });
|
|
282
|
+
const labels = Array.from(document.querySelectorAll(".context-menu-item")).map((b) => b.textContent);
|
|
283
|
+
expect(labels.some((l) => l?.includes("New session"))).toBe(true);
|
|
284
|
+
expect(labels.some((l) => l?.includes("Group by project"))).toBe(true);
|
|
285
|
+
// Session-specific verbs belong to a card's menu, never the background.
|
|
286
|
+
expect(labels.some((l) => l?.includes("Rename"))).toBe(false);
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("a right-click on a card does not open the wall menu", () => {
|
|
290
|
+
render(<SessionSwitcher {...props} open />);
|
|
291
|
+
const card = document.querySelector(".switcher-card")!;
|
|
292
|
+
fireEvent.contextMenu(card, { clientX: 10, clientY: 10 });
|
|
293
|
+
const labels = Array.from(document.querySelectorAll(".context-menu-item")).map((b) => b.textContent);
|
|
294
|
+
expect(labels.some((l) => l?.includes("New session"))).toBe(false);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("Escape closes the menu", () => {
|
|
298
|
+
render(<SessionSwitcher {...props} open />);
|
|
299
|
+
fireEvent.contextMenu(document.querySelector(".switcher-scroll")!, { clientX: 30, clientY: 30 });
|
|
300
|
+
expect(document.querySelector(".context-menu")).toBeTruthy();
|
|
301
|
+
fireEvent.keyDown(window, { key: "Escape" });
|
|
302
|
+
expect(document.querySelector(".context-menu")).toBeNull();
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
describe("manual folders in the wall", () => {
|
|
307
|
+
afterEach(() => { document.body.innerHTML = ""; });
|
|
308
|
+
const folders = [{ id: "f1", name: "Filed" }];
|
|
309
|
+
const withFolder: SwitcherSession[] = [
|
|
310
|
+
{ name: "inside", displayName: "inside", internalName: "inside", active: true, starting: false, createdBy: "user", folderId: "f1" },
|
|
311
|
+
{ name: "outside", displayName: "outside", internalName: "outside", active: true, starting: false, createdBy: "user" }
|
|
312
|
+
];
|
|
313
|
+
|
|
314
|
+
it("a filter matching only a foldered session shows it, not an empty wall", () => {
|
|
315
|
+
render(<SessionSwitcher {...props} sessions={withFolder} folders={folders} open />);
|
|
316
|
+
fireEvent.click(screen.getByRole("button", { name: "Manual" }));
|
|
317
|
+
const input = document.querySelector(".switcher-top input") as HTMLInputElement
|
|
318
|
+
|| document.querySelector("input") as HTMLInputElement;
|
|
319
|
+
fireEvent.change(input, { target: { value: "insid" } });
|
|
320
|
+
expect(screen.queryByText("No matches")).toBeNull();
|
|
321
|
+
expect(screen.getByText("inside")).toBeTruthy();
|
|
322
|
+
expect(screen.getByText("Filed")).toBeTruthy();
|
|
323
|
+
});
|
|
324
|
+
});
|
|
@@ -310,3 +310,68 @@ describe("manual mode stability", () => {
|
|
|
310
310
|
expect(after.rows.map((r) => r.name)).toEqual(["c", "a", "b"]);
|
|
311
311
|
});
|
|
312
312
|
});
|
|
313
|
+
|
|
314
|
+
describe("manual mode folders", () => {
|
|
315
|
+
const folders = [{ id: "f1", name: "Experiments" }, { id: "f2", name: "Infra" }];
|
|
316
|
+
|
|
317
|
+
it("files sessions into their folders and leaves the rest loose", () => {
|
|
318
|
+
const sessions = [
|
|
319
|
+
mk({ name: "alpha", folderId: "f1" }),
|
|
320
|
+
mk({ name: "beta" }),
|
|
321
|
+
mk({ name: "gamma", folderId: "f2" })
|
|
322
|
+
];
|
|
323
|
+
const model = buildSwitcherModel(sessions, null, "", "manual", [], folders);
|
|
324
|
+
if (model.mode !== "manual") throw new Error("expected manual");
|
|
325
|
+
expect(model.folders.map((f) => f.folder.name)).toEqual(["Experiments", "Infra"]);
|
|
326
|
+
expect(model.folders[0].rows.map((r) => r.name)).toEqual(["alpha"]);
|
|
327
|
+
expect(model.rows.map((r) => r.name)).toEqual(["beta"]);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("keeps an empty folder visible — a folder is a place you intend to fill", () => {
|
|
331
|
+
const model = buildSwitcherModel([mk({ name: "solo" })], null, "", "manual", [], folders);
|
|
332
|
+
if (model.mode !== "manual") throw new Error("expected manual");
|
|
333
|
+
expect(model.folders.map((f) => f.rows.length)).toEqual([0, 0]);
|
|
334
|
+
expect(model.rows.map((r) => r.name)).toEqual(["solo"]);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("a session in a deleted folder falls back to loose rather than vanishing", () => {
|
|
338
|
+
const sessions = [mk({ name: "orphan", folderId: "f_gone" })];
|
|
339
|
+
const model = buildSwitcherModel(sessions, null, "", "manual", [], folders);
|
|
340
|
+
if (model.mode !== "manual") throw new Error("expected manual");
|
|
341
|
+
expect(model.rows.map((r) => r.name)).toEqual(["orphan"]);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("filtering narrows inside folders instead of pulling sessions out of them", () => {
|
|
345
|
+
const sessions = [
|
|
346
|
+
mk({ name: "alpha", folderId: "f1" }),
|
|
347
|
+
mk({ name: "alpine", folderId: "f1" }),
|
|
348
|
+
mk({ name: "beta" })
|
|
349
|
+
];
|
|
350
|
+
const model = buildSwitcherModel(sessions, null, "alp", "manual", [], folders);
|
|
351
|
+
if (model.mode !== "manual") throw new Error("expected manual");
|
|
352
|
+
expect(model.folders[0].rows.map((r) => r.name)).toEqual(["alpha", "alpine"]);
|
|
353
|
+
expect(model.rows).toEqual([]);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("folders do not leak into Recent or Project", () => {
|
|
357
|
+
const sessions = [mk({ name: "alpha", folderId: "f1" }), mk({ name: "beta" })];
|
|
358
|
+
const recent = buildSwitcherModel(sessions, null, "", "recent", [], folders);
|
|
359
|
+
const project = buildSwitcherModel(sessions, null, "", "project", [], folders);
|
|
360
|
+
expect(recent.mode).toBe("tiers");
|
|
361
|
+
expect(project.mode).toBe("project");
|
|
362
|
+
if (project.mode !== "project") throw new Error("expected project");
|
|
363
|
+
expect(project.groups.some((g) => g.label === "Experiments")).toBe(false);
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
describe("manual mode folders under filter", () => {
|
|
368
|
+
const folders = [{ id: "f1", name: "Experiments" }];
|
|
369
|
+
|
|
370
|
+
it("matches inside folders are found and stay filed", () => {
|
|
371
|
+
const sessions = [mk({ name: "alpha", folderId: "f1" }), mk({ name: "beta" })];
|
|
372
|
+
const model = buildSwitcherModel(sessions, null, "alph", "manual", [], folders);
|
|
373
|
+
if (model.mode !== "manual") throw new Error("expected manual");
|
|
374
|
+
expect(model.folders[0].rows.map((r) => r.name)).toEqual(["alpha"]);
|
|
375
|
+
expect(model.rows).toEqual([]); // loose list empty ≠ no results
|
|
376
|
+
});
|
|
377
|
+
});
|