Skip to content

Commit f9e0964

Browse files
committed
Add FallSession command to manage picker sessions
1 parent b9b6881 commit f9e0964

File tree

10 files changed

+205
-13
lines changed

10 files changed

+205
-13
lines changed

autoload/fall/command/FallSession.vim

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
function! fall#command#FallSession#call() abort
2+
if denops#plugin#wait('fall') isnot# 0
3+
return
4+
endif
5+
try
6+
call fall#internal#picker#setup()
7+
call denops#request('fall', 'picker:session:command', [])
8+
finally
9+
call fall#internal#picker#teardown()
10+
endtry
11+
endfunction
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import type { Action } from "jsr:@vim-fall/core@^0.3.0/action";
2+
import type { Detail } from "../source/session.ts";
3+
4+
export const defaultSessionActions = {
5+
resume: {
6+
invoke: async (denops, { item }) => {
7+
if (!item) {
8+
return;
9+
}
10+
// we need to use timer_start to avoid nesting pickers
11+
await denops.cmd(
12+
`call timer_start(0, { -> execute('FallResume ${item.value}') })`,
13+
);
14+
},
15+
},
16+
} satisfies Record<string, Action<Detail>>;
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type { PreviewItem } from "jsr:@vim-fall/core@^0.3.0/item";
2+
import type { Previewer } from "jsr:@vim-fall/core@^0.3.0/previewer";
3+
import { definePreviewer } from "jsr:@vim-fall/std@^0.10.0/previewer";
4+
import type { Detail } from "../source/session.ts";
5+
import { decompressPickerSession } from "../../session.ts";
6+
7+
export function session(): Previewer<Detail> {
8+
return definePreviewer(async (_denops, { item }, { signal }) => {
9+
if (!item || signal?.aborted) {
10+
return undefined;
11+
}
12+
13+
try {
14+
// Decompress the session to access its data
15+
const session = await decompressPickerSession(item.detail);
16+
17+
const lines: string[] = [];
18+
19+
// Add session info
20+
lines.push(`# Session: ${item.value}`);
21+
lines.push("");
22+
lines.push(`Source: ${session.name}`);
23+
lines.push(`Query: ${session.context.query || "(empty)"}`);
24+
lines.push(`Total items: ${session.context.collectedItems.length}`);
25+
lines.push(`Filtered items: ${session.context.filteredItems.length}`);
26+
lines.push("");
27+
lines.push("## Filtered Items:");
28+
lines.push("");
29+
30+
// Show filtered items with selection status
31+
const selection = session.context.selection;
32+
for (const filteredItem of session.context.filteredItems) {
33+
const isSelected = selection.has(filteredItem.id);
34+
const prefix = isSelected ? "[x]" : "[ ]";
35+
lines.push(`${prefix} ${filteredItem.value}`);
36+
}
37+
38+
if (session.context.filteredItems.length === 0) {
39+
lines.push("(no items)");
40+
}
41+
42+
return {
43+
content: lines,
44+
filetype: "markdown",
45+
} satisfies PreviewItem;
46+
} catch (error) {
47+
return {
48+
content: [`Error loading session preview: ${error}`],
49+
} satisfies PreviewItem;
50+
}
51+
});
52+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import type { Denops } from "jsr:@denops/std@^7.3.2";
2+
import type { Renderer } from "jsr:@vim-fall/core@^0.3.0/renderer";
3+
import type { DisplayItem } from "jsr:@vim-fall/core@^0.3.0/item";
4+
import type { Detail } from "../source/session.ts";
5+
6+
export function session(): Renderer<Detail> {
7+
return {
8+
render(
9+
_denops: Denops,
10+
{ items }: { items: DisplayItem<Detail>[] },
11+
{ signal }: { signal?: AbortSignal },
12+
): void {
13+
for (const item of items) {
14+
if (signal?.aborted) break;
15+
item.label = [
16+
item.value,
17+
item.detail.name,
18+
...item.detail.args,
19+
].join(" ");
20+
}
21+
},
22+
};
23+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import type { Source } from "jsr:@vim-fall/core@^0.3.0/source";
2+
import type { DetailUnit, IdItem } from "jsr:@vim-fall/core@^0.3.0/item";
3+
import type { PickerSessionCompressed } from "../../session.ts";
4+
import { listPickerSessions } from "../../session.ts";
5+
6+
export type Detail = PickerSessionCompressed<DetailUnit>;
7+
8+
export function session(): Source<Detail> {
9+
return {
10+
collect: async function* (): AsyncIterableIterator<IdItem<Detail>> {
11+
const sessions = listPickerSessions();
12+
yield* sessions.map((session, index) => {
13+
const number = index + 1;
14+
return {
15+
id: index,
16+
value: `#${number}`,
17+
detail: session,
18+
};
19+
});
20+
},
21+
};
22+
}

denops/fall/main/picker.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,19 @@ import {
2121
loadPickerSession,
2222
savePickerSession,
2323
} from "../session.ts";
24+
import type { Detail as SessionDetail } from "../extension/source/session.ts";
25+
import { session as sessionSource } from "../extension/source/session.ts";
26+
import { session as sessionRenderer } from "../extension/renderer/session.ts";
27+
import { session as sessionPreviewer } from "../extension/previewer/session.ts";
28+
import { defaultSessionActions } from "../extension/action/session.ts";
2429

2530
let zindex = 50;
2631

32+
const SESSION_EXCLUDE_SOURCES = [
33+
"@action",
34+
"@session",
35+
];
36+
2737
export const main: Entrypoint = (denops) => {
2838
denops.dispatcher = {
2939
...denops.dispatcher,
@@ -74,6 +84,30 @@ export const main: Entrypoint = (denops) => {
7484
});
7585
},
7686
),
87+
"picker:session:command": withHandleError(denops, async () => {
88+
await loadUserCustom(denops);
89+
const { substring } = await import(
90+
"jsr:@vim-fall/std@^0.10.0/builtin/matcher/substring"
91+
);
92+
const setting = getSetting();
93+
const sessionPickerParams = {
94+
name: "@session",
95+
source: sessionSource(),
96+
matchers: [substring()] as const,
97+
sorters: [],
98+
renderers: [sessionRenderer()],
99+
previewers: [sessionPreviewer()],
100+
actions: defaultSessionActions,
101+
defaultAction: "resume",
102+
...setting,
103+
} as PickerParams<SessionDetail, string>;
104+
await startPicker(
105+
denops,
106+
[],
107+
sessionPickerParams,
108+
{ signal: denops.interrupted },
109+
);
110+
}),
77111
"picker:resume:command:complete": withHandleError(
78112
denops,
79113
async (arglead, cmdline, cursorpos) => {
@@ -179,8 +213,12 @@ async function startPicker<T extends Detail>(
179213
zindex -= Picker.ZINDEX_ALLOCATION;
180214
});
181215
stack.defer(async () => {
216+
const name = pickerParams.name;
217+
if (SESSION_EXCLUDE_SOURCES.includes(name)) {
218+
return;
219+
}
182220
await savePickerSession({
183-
name: pickerParams.name,
221+
name,
184222
args,
185223
context: itemPicker.context,
186224
});

denops/fall/session.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,15 @@ async function compressPickerSession<T extends Detail>(
5151
session: PickerSession<T>,
5252
): Promise<PickerSessionCompressed<T>> {
5353
const encoder = new TextEncoder();
54+
// Convert Set to Array for JSON serialization
55+
const contextForSerialization = {
56+
...session.context,
57+
selection: Array.from(session.context.selection),
58+
};
5459
return {
5560
...session,
5661
context: await brotli.compress(
57-
encoder.encode(JSON.stringify(session.context)),
62+
encoder.encode(JSON.stringify(contextForSerialization)),
5863
),
5964
};
6065
}
@@ -65,15 +70,20 @@ async function compressPickerSession<T extends Detail>(
6570
* @param compressed - The compressed session to decompress
6671
* @returns A promise that resolves to the decompressed session
6772
*/
68-
async function decompressPickerSession<T extends Detail>(
73+
export async function decompressPickerSession<T extends Detail>(
6974
compressed: PickerSessionCompressed<T>,
7075
): Promise<PickerSession<T>> {
7176
const decoder = new TextDecoder();
77+
const decompressedContext = JSON.parse(
78+
decoder.decode(await brotli.uncompress(compressed.context)),
79+
);
80+
// Convert selection array back to Set
7281
return {
7382
...compressed,
74-
context: JSON.parse(
75-
decoder.decode(await brotli.uncompress(compressed.context)),
76-
),
83+
context: {
84+
...decompressedContext,
85+
selection: new Set(decompressedContext.selection),
86+
},
7787
};
7888
}
7989

denops/fall/session_test.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -203,10 +203,8 @@ Deno.test("session management", async (t) => {
203203
assertEquals(loadedSession.context.previewerIndex, 4);
204204
assertEquals(loadedSession.context.collectedItems, testItems);
205205
assertEquals(loadedSession.context.filteredItems, testItems);
206-
// Check that selection was preserved (Sets are converted to empty objects in JSON)
207-
// The selection will be an empty object after deserialization since Set is not JSON serializable
208-
assertEquals(typeof loadedSession.context.selection, "object");
209-
assertEquals(loadedSession.context.selection, {} as unknown);
206+
// Check that selection was preserved as a Set
207+
assertEquals(loadedSession.context.selection, new Set(["test-1"]));
210208
},
211209
);
212210

@@ -251,9 +249,8 @@ Deno.test("session management", async (t) => {
251249

252250
assertExists(loaded);
253251
assertEquals(loaded.context.query, "");
254-
// Selection will be deserialized as an empty object, not a Set
255-
assertEquals(typeof loaded.context.selection, "object");
256-
assertEquals(loaded.context.selection, {} as unknown);
252+
// Selection will be deserialized as an empty Set
253+
assertEquals(loaded.context.selection, new Set());
257254
assertEquals(loaded.context.collectedItems.length, 0);
258255
assertEquals(loaded.context.filteredItems.length, 0);
259256
assertEquals(loaded.context.cursor, 0);

doc/fall.txt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,28 @@ COMMAND *fall-command*
433433
:FallResume file#2
434434
<
435435

436+
*:FallSession*
437+
:FallSession
438+
Open a picker to browse and resume previous picker sessions. Fall
439+
automatically stores up to 100 picker sessions in memory, allowing
440+
you to return to previous searches with their complete state including
441+
query, filtered items, and selections.
442+
443+
Sessions are listed in reverse chronological order (newest first) and
444+
display the session number, source name, and any arguments used.
445+
Selecting a session resumes it with its exact state.
446+
447+
The following sources are excluded from session storage:
448+
- "@action" (action selection picker)
449+
- "@session" (the session picker itself)
450+
451+
Sessions are stored in compressed format to minimize memory usage.
452+
>
453+
" Open the session picker
454+
:FallSession
455+
<
456+
See |:FallResume| for directly resuming sessions by number or source.
457+
436458
*:FallCustom*
437459
:FallCustom
438460
Open "custom.ts" for customization. This TypeScript file exports a

plugin/fall.vim

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ command! -nargs=+ -complete=customlist,fall#command#Fall#complete
88
\ Fall call fall#command#Fall#call([<f-args>])
99
command! -nargs=? -complete=customlist,fall#command#FallResume#complete
1010
\ FallResume call fall#command#FallResume#call(<q-args>)
11+
command! -nargs=0 FallSession call fall#command#FallSession#call()
1112

1213
command! -nargs=0 FallCustom call fall#command#FallCustom#call()
1314
command! -nargs=0 FallCustomReload call fall#command#FallCustomReload#call()

0 commit comments

Comments
 (0)