1
0
silverbullet/web/client.ts

727 lines
20 KiB
TypeScript
Raw Normal View History

// Third party web dependencies
2022-04-25 08:33:38 +00:00
import {
CompletionContext,
2022-04-25 08:33:38 +00:00
CompletionResult,
2022-04-04 13:25:07 +00:00
EditorView,
2023-05-29 07:53:49 +00:00
gitIgnoreCompiler,
syntaxTree,
} from "../common/deps.ts";
import { Space } from "./space.ts";
import { FilterOption, PageMeta } from "./types.ts";
2023-07-14 14:48:35 +00:00
import { parseYamlSettings } from "../common/util.ts";
import { EventHook } from "../plugos/hooks/event.ts";
2023-07-14 11:44:30 +00:00
import { AppCommand } from "./hooks/command.ts";
2022-12-21 15:08:51 +00:00
import { PathPageNavigator } from "./navigator.ts";
2023-07-14 11:58:16 +00:00
2023-07-14 14:48:35 +00:00
import { AppViewState, BuiltinSettings } from "./types.ts";
2023-07-14 11:58:16 +00:00
import type { AppEvent, CompleteEvent } from "../plug-api/app_event.ts";
2023-01-14 17:51:00 +00:00
import { throttle } from "../common/async_util.ts";
import { PlugSpacePrimitives } from "../common/spaces/plug_space_primitives.ts";
import { IndexedDBSpacePrimitives } from "../common/spaces/indexeddb_space_primitives.ts";
import { FileMetaSpacePrimitives } from "../common/spaces/file_meta_space_primitives.ts";
import { EventedSpacePrimitives } from "../common/spaces/evented_space_primitives.ts";
import { SyncService } from "./sync_service.ts";
import { simpleHash } from "../common/crypto.ts";
import { DexieKVStore } from "../plugos/lib/kv_store.dexie.ts";
import { SyncStatus } from "../common/spaces/sync.ts";
import { HttpSpacePrimitives } from "../common/spaces/http_space_primitives.ts";
import { FallbackSpacePrimitives } from "../common/spaces/fallback_space_primitives.ts";
2023-05-29 07:53:49 +00:00
import { FilteredSpacePrimitives } from "../common/spaces/filtered_space_primitives.ts";
import { isValidPageName } from "$sb/lib/page.ts";
2023-07-14 11:44:30 +00:00
import { ClientSystem } from "./client_system.ts";
2023-07-14 11:58:16 +00:00
import { createEditorState } from "./editor_state.ts";
import { OpenPages } from "./open_pages.ts";
2023-07-14 12:22:26 +00:00
import { MainUI } from "./editor_ui.tsx";
2023-07-14 11:44:30 +00:00
const frontMatterRegex = /^---\n(([^\n]|\n)*?)---\n/;
2023-07-14 11:44:30 +00:00
const autoSaveInterval = 1000;
declare global {
interface Window {
// Injected via index.html
silverBulletConfig: {
spaceFolderPath: string;
};
2023-07-14 14:56:20 +00:00
client: Client;
}
}
// TODO: Oh my god, need to refactor this
2023-07-14 14:56:20 +00:00
export class Client {
editorView?: EditorView;
2023-07-14 11:44:30 +00:00
pageNavigator?: PathPageNavigator;
2022-04-07 13:21:30 +00:00
space: Space;
2023-07-14 11:44:30 +00:00
remoteSpacePrimitives: HttpSpacePrimitives;
plugSpaceRemotePrimitives: PlugSpacePrimitives;
2023-07-14 11:44:30 +00:00
saveTimeout?: number;
2022-12-22 15:20:05 +00:00
2022-04-04 13:25:07 +00:00
debouncedUpdateEvent = throttle(() => {
this.eventHook
.dispatchEvent("editor:updated")
.catch((e) => console.error("Error dispatching editor:updated event", e));
2022-04-04 13:25:07 +00:00
}, 1000);
2023-07-14 11:44:30 +00:00
system: ClientSystem;
// Track if plugs have been updated since sync cycle
fullSyncCompleted = false;
2022-12-16 11:44:04 +00:00
// Runtime state (that doesn't make sense in viewState)
syncService: SyncService;
settings?: BuiltinSettings;
kvStore: DexieKVStore;
2023-07-14 11:44:30 +00:00
// Event bus used to communicate between components
eventHook: EventHook;
2023-07-14 12:22:26 +00:00
ui: MainUI;
2023-07-14 11:58:16 +00:00
openPages: OpenPages;
2023-07-14 11:44:30 +00:00
2022-08-02 10:43:39 +00:00
constructor(
parent: Element,
) {
const runtimeConfig = window.silverBulletConfig;
// Generate a semi-unique prefix for the database so not to reuse databases for different space paths
2023-07-14 11:44:30 +00:00
const dbPrefix = "" + simpleHash(window.silverBulletConfig.spaceFolderPath);
this.kvStore = new DexieKVStore(
`${dbPrefix}_store`,
"data",
globalThis.indexedDB,
);
2023-07-14 11:44:30 +00:00
// Event hook
this.eventHook = new EventHook();
// Instantiate a PlugOS system
this.system = new ClientSystem(
this,
this.kvStore,
dbPrefix,
this.eventHook,
);
// Setup space
this.remoteSpacePrimitives = new HttpSpacePrimitives(
location.origin,
runtimeConfig.spaceFolderPath,
true,
);
this.plugSpaceRemotePrimitives = new PlugSpacePrimitives(
this.remoteSpacePrimitives,
2023-07-14 11:44:30 +00:00
this.system.namespaceHook,
);
2023-05-29 07:53:49 +00:00
let fileFilterFn: (s: string) => boolean = () => true;
2023-07-14 11:44:30 +00:00
2023-05-29 07:53:49 +00:00
const localSpacePrimitives = new FilteredSpacePrimitives(
new FileMetaSpacePrimitives(
new EventedSpacePrimitives(
// Using fallback space primitives here to allow (by default) local reads to "fall through" to HTTP when files aren't synced yet
new FallbackSpacePrimitives(
new IndexedDBSpacePrimitives(
`${dbPrefix}_space`,
globalThis.indexedDB,
),
this.plugSpaceRemotePrimitives,
),
2023-05-29 07:53:49 +00:00
this.eventHook,
),
2023-07-14 11:44:30 +00:00
this.system.indexSyscalls,
),
2023-05-29 07:53:49 +00:00
(meta) => fileFilterFn(meta.name),
async () => {
await this.loadSettings();
if (typeof this.settings?.spaceIgnore === "string") {
fileFilterFn = gitIgnoreCompiler(this.settings.spaceIgnore).accepts;
} else {
fileFilterFn = () => true;
}
},
);
2023-05-29 08:26:56 +00:00
this.space = new Space(localSpacePrimitives, this.kvStore);
this.space.watch();
this.syncService = new SyncService(
localSpacePrimitives,
this.plugSpaceRemotePrimitives,
this.kvStore,
this.eventHook,
(path) => {
// TODO: At some point we should remove the data.db exception here
return path !== "data.db" &&
// Exclude all plug space primitives paths
!this.plugSpaceRemotePrimitives.isLikelyHandled(path) ||
// Except federated ones
path.startsWith("!");
},
);
2023-07-14 12:22:26 +00:00
this.ui = new MainUI(this);
this.ui.render(parent);
2022-10-25 16:50:07 +00:00
this.editorView = new EditorView({
2023-07-14 11:58:16 +00:00
state: createEditorState(this, "", "", false),
2022-07-22 11:44:28 +00:00
parent: document.getElementById("sb-editor")!,
});
2023-07-14 11:58:16 +00:00
this.openPages = new OpenPages(this.editorView);
}
get currentPage(): string | undefined {
2023-07-14 12:22:26 +00:00
return this.ui.viewState.currentPage;
}
async init() {
this.focus();
2022-11-24 11:04:00 +00:00
this.space.on({
pageChanged: (meta) => {
// Only reload when watching the current page (to avoid reloading when switching pages)
if (this.space.watchInterval && this.currentPage === meta.name) {
console.log("Page changed elsewhere, reloading");
this.flashNotification("Page changed elsewhere, reloading");
2022-11-24 11:04:00 +00:00
this.reloadPage();
}
},
pageListUpdated: (pages) => {
2023-07-14 12:22:26 +00:00
this.ui.viewDispatch({
2022-11-24 11:04:00 +00:00
type: "pages-listed",
pages: pages,
});
},
});
// Load settings
this.settings = await this.loadSettings();
this.pageNavigator = new PathPageNavigator(
this.settings.indexPage,
);
2022-11-24 11:04:00 +00:00
await this.reloadPlugs();
2022-08-30 08:44:20 +00:00
this.pageNavigator.subscribe(async (pageName, pos: number | string) => {
2022-09-13 06:41:01 +00:00
console.log("Now navigating to", pageName);
if (!this.editorView) {
return;
}
const stateRestored = await this.loadPage(pageName);
2022-03-28 13:25:05 +00:00
if (pos) {
2022-08-30 08:44:20 +00:00
if (typeof pos === "string") {
2022-10-24 17:40:52 +00:00
console.log("Navigating to anchor", pos);
2022-08-30 08:44:20 +00:00
// We're going to look up the anchor through a direct page store query...
2023-07-14 11:44:30 +00:00
// TODO: This should be extracted
const posLookup = await this.system.localSyscall(
"index.get",
[
pageName,
2022-10-24 17:40:52 +00:00
`a:${pageName}:${pos}`,
],
);
2022-08-30 08:44:20 +00:00
if (!posLookup) {
return this.flashNotification(
`Could not find anchor @${pos}`,
"error",
2022-08-30 08:44:20 +00:00
);
} else {
pos = +posLookup;
}
}
2022-03-28 13:25:05 +00:00
this.editorView.dispatch({
selection: { anchor: pos },
effects: EditorView.scrollIntoView(pos, { y: "start" }),
2022-03-28 13:25:05 +00:00
});
2022-09-06 14:33:00 +00:00
} else if (!stateRestored) {
2022-11-24 15:55:30 +00:00
// Somewhat ad-hoc way to determine if the document contains frontmatter and if so, putting the cursor _after it_.
const pageText = this.editorView.state.sliceDoc();
// Default the cursor to be at position 0
let initialCursorPos = 0;
const match = frontMatterRegex.exec(pageText);
if (match) {
2022-11-29 08:17:40 +00:00
// Frontmatter found, put cursor after it
2022-12-19 11:35:58 +00:00
initialCursorPos = match[0].length;
2022-11-24 15:55:30 +00:00
}
// By default scroll to the top
this.editorView.scrollDOM.scrollTop = 0;
2022-09-06 14:33:00 +00:00
this.editorView.dispatch({
2022-11-24 15:55:30 +00:00
selection: { anchor: initialCursorPos },
// And then scroll down if required
2022-09-06 14:33:00 +00:00
scrollIntoView: true,
});
2022-03-28 13:25:05 +00:00
}
});
this.loadCustomStyles().catch(console.error);
2023-01-22 14:48:12 +00:00
// Kick off background sync
this.syncService.start();
this.eventHook.addLocalListener("sync:success", async (operations) => {
2023-05-29 07:53:49 +00:00
// console.log("Operations", operations);
if (operations > 0) {
// Update the page list
await this.space.updatePageList();
}
if (operations !== undefined) {
// "sync:success" is called with a number of operations only from syncSpace(), not from syncing individual pages
this.fullSyncCompleted = true;
}
2023-07-14 11:44:30 +00:00
if (this.system.plugsUpdated) {
// To register new commands, update editor state based on new plugs
this.rebuildEditorState();
this.dispatchAppEvent("editor:pageLoaded", this.currentPage);
if (operations) {
// Likely initial sync so let's show visually that we're synced now
2023-06-14 18:58:08 +00:00
// this.flashNotification(`Synced ${operations} files`, "info");
this.showProgress(100);
}
}
// Reset for next sync cycle
2023-07-14 11:44:30 +00:00
this.system.plugsUpdated = false;
2023-07-14 12:22:26 +00:00
this.ui.viewDispatch({ type: "sync-change", synced: true });
});
2023-07-14 14:56:20 +00:00
this.eventHook.addLocalListener("sync:error", (_name) => {
2023-07-14 12:22:26 +00:00
this.ui.viewDispatch({ type: "sync-change", synced: false });
});
this.eventHook.addLocalListener("sync:conflict", (name) => {
this.flashNotification(
`Sync: conflict detected for ${name} - conflict copy created`,
"error",
);
});
this.eventHook.addLocalListener("sync:progress", (status: SyncStatus) => {
2023-06-14 18:58:08 +00:00
this.showProgress(
Math.round(status.filesProcessed / status.totalFiles * 100),
);
});
2022-07-11 07:08:22 +00:00
await this.dispatchAppEvent("editor:init");
}
async loadSettings(): Promise<BuiltinSettings> {
let settingsText: string | undefined;
try {
settingsText = (await this.space.readPage("SETTINGS")).text;
2023-07-14 14:56:20 +00:00
} catch {
console.log("No SETTINGS page, falling back to default");
settingsText = "```yaml\nindexPage: index\n```\n";
}
const settings = parseYamlSettings(settingsText!) as BuiltinSettings;
if (!settings.indexPage) {
settings.indexPage = "index";
}
return settings;
}
save(immediate = false): Promise<void> {
return new Promise((resolve, reject) => {
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
}
this.saveTimeout = setTimeout(
() => {
if (this.currentPage) {
if (
2023-07-14 12:22:26 +00:00
!this.ui.viewState.unsavedChanges ||
this.ui.viewState.uiOptions.forcedROMode
) {
2022-10-22 18:23:54 +00:00
// No unsaved changes, or read-only mode, not gonna save
2022-10-21 17:02:00 +00:00
return resolve();
}
console.log("Saving page", this.currentPage);
this.space
.writePage(
this.currentPage,
this.editorView!.state.sliceDoc(0),
true,
)
.then(async (meta) => {
2023-07-14 12:22:26 +00:00
this.ui.viewDispatch({ type: "page-saved" });
await this.dispatchAppEvent(
"editor:pageSaved",
this.currentPage,
meta,
);
resolve();
})
2022-07-19 15:21:11 +00:00
.catch((e) => {
this.flashNotification(
"Could not save page, retrying again in 10 seconds",
"error",
2022-07-19 15:21:11 +00:00
);
this.saveTimeout = setTimeout(this.save.bind(this), 10000);
reject(e);
});
} else {
resolve();
}
},
2023-07-14 11:44:30 +00:00
immediate ? 0 : autoSaveInterval,
);
});
}
flashNotification(message: string, type: "info" | "error" = "info") {
const id = Math.floor(Math.random() * 1000000);
2023-07-14 12:22:26 +00:00
this.ui.viewDispatch({
type: "show-notification",
notification: {
id,
type,
message,
date: new Date(),
},
});
setTimeout(
() => {
2023-07-14 12:22:26 +00:00
this.ui.viewDispatch({
type: "dismiss-notification",
id: id,
});
},
type === "info" ? 4000 : 5000,
);
}
2023-06-14 18:58:08 +00:00
progressTimeout?: number;
showProgress(progressPerc: number) {
2023-07-14 12:22:26 +00:00
this.ui.viewDispatch({
2023-06-14 18:58:08 +00:00
type: "set-progress",
progressPerc,
});
if (this.progressTimeout) {
clearTimeout(this.progressTimeout);
}
this.progressTimeout = setTimeout(
() => {
2023-07-14 12:22:26 +00:00
this.ui.viewDispatch({
2023-06-14 18:58:08 +00:00
type: "set-progress",
});
},
10000,
);
}
filterBox(
label: string,
options: FilterOption[],
helpText = "",
placeHolder = "",
): Promise<FilterOption | undefined> {
return new Promise((resolve) => {
2023-07-14 12:22:26 +00:00
this.ui.viewDispatch({
type: "show-filterbox",
label,
options,
placeHolder,
helpText,
onSelect: (option: any) => {
2023-07-14 12:22:26 +00:00
this.ui.viewDispatch({ type: "hide-filterbox" });
this.focus();
resolve(option);
},
});
});
}
2022-12-21 15:08:51 +00:00
prompt(
message: string,
defaultValue = "",
): Promise<string | undefined> {
return new Promise((resolve) => {
2023-07-14 12:22:26 +00:00
this.ui.viewDispatch({
2022-12-21 15:08:51 +00:00
type: "show-prompt",
message,
defaultValue,
callback: (value: string | undefined) => {
2023-07-14 12:22:26 +00:00
this.ui.viewDispatch({ type: "hide-prompt" });
2022-12-21 15:08:51 +00:00
this.focus();
resolve(value);
},
});
});
}
confirm(
message: string,
): Promise<boolean> {
return new Promise((resolve) => {
2023-07-14 12:22:26 +00:00
this.ui.viewDispatch({
2022-12-21 15:08:51 +00:00
type: "show-confirm",
message,
callback: (value: boolean) => {
2023-07-14 12:22:26 +00:00
this.ui.viewDispatch({ type: "hide-confirm" });
2022-12-21 15:08:51 +00:00
this.focus();
resolve(value);
},
});
});
}
dispatchAppEvent(name: AppEvent, ...args: any[]): Promise<any[]> {
return this.eventHook.dispatchEvent(name, ...args);
}
2022-04-26 17:04:36 +00:00
async reloadPlugs() {
console.log("Loading plugs");
2023-07-14 11:44:30 +00:00
await this.system.reloadPlugsFromSpace(this.space);
2022-04-26 17:04:36 +00:00
this.rebuildEditorState();
2022-07-11 07:08:22 +00:00
await this.dispatchAppEvent("plugs:loaded");
2022-04-26 17:04:36 +00:00
}
rebuildEditorState() {
const editorView = this.editorView;
console.log("Rebuilding editor state");
2023-07-14 11:44:30 +00:00
this.system.updateMarkdownParser();
2022-04-11 18:34:09 +00:00
if (editorView && this.currentPage) {
// And update the editor if a page is loaded
2023-07-14 11:58:16 +00:00
this.openPages.saveState(this.currentPage);
2022-04-26 17:04:36 +00:00
2022-03-31 15:25:34 +00:00
editorView.setState(
2023-07-14 11:58:16 +00:00
createEditorState(
this,
2023-01-16 10:28:59 +00:00
this.currentPage,
editorView.state.sliceDoc(),
2023-07-14 12:22:26 +00:00
this.ui.viewState.currentPageMeta?.perm === "ro",
2023-01-16 10:28:59 +00:00
),
2022-03-31 15:25:34 +00:00
);
if (editorView.contentDOM) {
2022-05-17 09:53:17 +00:00
this.tweakEditorDOM(
editorView.contentDOM,
);
}
2022-04-26 17:04:36 +00:00
2023-07-14 11:58:16 +00:00
this.openPages.restoreState(this.currentPage);
2022-03-31 15:25:34 +00:00
}
}
// Code completion support
private async completeWithEvent(
context: CompletionContext,
eventName: AppEvent,
): Promise<CompletionResult | null> {
const editorState = context.state;
const selection = editorState.selection.main;
const line = editorState.doc.lineAt(selection.from);
const linePrefix = line.text.slice(0, selection.from - line.from);
const results = await this.dispatchAppEvent(eventName, {
pageName: this.currentPage!,
linePrefix,
pos: selection.from,
} as CompleteEvent);
let actualResult = null;
for (const result of results) {
if (result) {
if (actualResult) {
console.error(
"Got completion results from multiple sources, cannot deal with that",
);
console.error("Previously had", actualResult, "now also got", result);
return null;
}
actualResult = result;
}
}
return actualResult;
}
editorComplete(
context: CompletionContext,
): Promise<CompletionResult | null> {
return this.completeWithEvent(context, "editor:complete");
}
miniEditorComplete(
context: CompletionContext,
): Promise<CompletionResult | null> {
return this.completeWithEvent(context, "minieditor:complete");
}
2022-10-10 16:19:08 +00:00
async reloadPage() {
console.log("Reloading page");
2022-10-10 16:19:08 +00:00
clearTimeout(this.saveTimeout);
await this.loadPage(this.currentPage!);
}
focus() {
this.editorView!.focus();
}
async navigate(
name: string,
pos?: number | string,
replaceState = false,
newWindow = false,
) {
2022-08-02 10:43:39 +00:00
if (!name) {
name = this.settings!.indexPage;
2022-08-02 10:43:39 +00:00
}
if (!isValidPageName(name)) {
return this.flashNotification(
2023-07-07 09:44:05 +00:00
"Invalid page name: page names cannot end with a file extension nor start with a '.'",
"error",
);
}
if (newWindow) {
const win = window.open(`${location.origin}/${name}`, "_blank");
if (win) {
win.focus();
}
return;
}
await this.pageNavigator!.navigate(name, pos, replaceState);
}
2022-09-06 14:33:00 +00:00
async loadPage(pageName: string): Promise<boolean> {
const loadingDifferentPage = pageName !== this.currentPage;
const editorView = this.editorView;
if (!editorView) {
2022-09-06 14:33:00 +00:00
return false;
}
2022-09-06 14:33:00 +00:00
const previousPage = this.currentPage;
// Persist current page state and nicely close page
2022-09-06 14:33:00 +00:00
if (previousPage) {
2023-07-14 11:58:16 +00:00
this.openPages.saveState(previousPage);
2022-09-06 14:33:00 +00:00
this.space.unwatchPage(previousPage);
2022-10-10 16:19:08 +00:00
if (previousPage !== pageName) {
await this.save(true);
}
}
2023-07-14 12:22:26 +00:00
this.ui.viewDispatch({
2022-09-12 12:50:37 +00:00
type: "page-loading",
name: pageName,
});
// Fetch next page to open
let doc;
try {
doc = await this.space.readPage(pageName);
if (doc.meta.contentType.startsWith("text/html")) {
throw new Error("Got HTML page, not markdown");
}
} catch (e: any) {
// Not found, new page
console.log("Creating new page", pageName);
doc = {
text: "",
2022-05-17 09:53:17 +00:00
meta: { name: pageName, lastModified: 0, perm: "rw" } as PageMeta,
};
}
2023-07-14 11:58:16 +00:00
const editorState = createEditorState(
this,
2023-01-16 10:28:59 +00:00
pageName,
doc.text,
doc.meta.perm === "ro",
);
editorView.setState(editorState);
if (editorView.contentDOM) {
2023-01-16 10:28:59 +00:00
this.tweakEditorDOM(editorView.contentDOM);
}
2023-07-14 11:58:16 +00:00
const stateRestored = this.openPages.restoreState(pageName);
this.space.watchPage(pageName);
2023-07-14 12:22:26 +00:00
this.ui.viewDispatch({
type: "page-loaded",
2022-05-17 09:53:17 +00:00
meta: doc.meta,
});
2022-04-04 13:25:07 +00:00
2022-11-24 15:55:30 +00:00
// Note: these events are dispatched asynchronously deliberately (not waiting for results)
if (loadingDifferentPage) {
this.eventHook.dispatchEvent("editor:pageLoaded", pageName, previousPage)
.catch(
console.error,
);
2022-07-18 14:48:36 +00:00
} else {
2022-11-24 15:55:30 +00:00
this.eventHook.dispatchEvent("editor:pageReloaded", pageName).catch(
console.error,
);
}
2022-09-06 14:33:00 +00:00
return stateRestored;
}
2023-01-16 10:28:59 +00:00
tweakEditorDOM(contentDOM: HTMLElement) {
2022-05-09 08:45:36 +00:00
contentDOM.spellcheck = true;
contentDOM.setAttribute("autocorrect", "on");
contentDOM.setAttribute("autocapitalize", "on");
}
async loadCustomStyles() {
2023-07-02 12:48:27 +00:00
if (this.settings?.customStyles) {
try {
const { text: stylesText } = await this.space.readPage(
this.settings?.customStyles,
);
const cssBlockRegex = /```css([^`]+)```/;
const match = cssBlockRegex.exec(stylesText);
if (!match) {
return;
}
const css = match[1];
document.getElementById("custom-styles")!.innerHTML = css;
} catch (e: any) {
console.error("Failed to load custom styles", e);
}
}
}
async runCommandByName(name: string, ...args: any[]) {
2023-07-14 12:22:26 +00:00
const cmd = this.ui.viewState.commands.get(name);
2022-07-11 07:08:22 +00:00
if (cmd) {
await cmd.run();
} else {
throw new Error(`Command ${name} not found`);
}
}
2023-07-14 12:22:26 +00:00
getCommandsByContext(
state: AppViewState,
): Map<string, AppCommand> {
const commands = new Map(state.commands);
for (const [k, v] of state.commands.entries()) {
if (
v.command.contexts &&
(!state.showCommandPaletteContext ||
!v.command.contexts.includes(state.showCommandPaletteContext))
) {
commands.delete(k);
}
}
return commands;
}
2023-07-14 11:58:16 +00:00
getContext(): string | undefined {
const state = this.editorView!.state;
const selection = state.selection.main;
if (selection.empty) {
return syntaxTree(state).resolveInner(selection.from).type.name;
}
return;
}
}