Compare commits
9 Commits
6d218cd23d
...
b8bff6f129
| Author | SHA1 | Date | |
|---|---|---|---|
| b8bff6f129 | |||
| 23c8d4822a | |||
| d1b16ce3e1 | |||
| 2c7508c2a4 | |||
| 82722ec9e4 | |||
| 04c2137fe2 | |||
| b378756c5d | |||
| 02e5ee45e8 | |||
| ff58f2b8ae |
10
package.json
10
package.json
|
|
@ -1,11 +1,15 @@
|
|||
{
|
||||
"name": "shrimp",
|
||||
"version": "0.1.0",
|
||||
"exports": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./editor": "./src/editor/index.ts",
|
||||
"./editor.css": "./src/editor/editor.css"
|
||||
},
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bun --hot src/server/server.tsx",
|
||||
"editor": "bun --hot src/editor/example/server.tsx",
|
||||
"repl": "bun bin/repl",
|
||||
"update-reef": "rm -rf ~/.bun/install/cache/ && rm bun.lock && bun update reefvm",
|
||||
"cli:install": "ln -s \"$(pwd)/bin/shrimp\" ~/.bun/bin/shrimp",
|
||||
|
|
@ -33,4 +37,4 @@
|
|||
"singleQuote": true,
|
||||
"printWidth": 100
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,261 +0,0 @@
|
|||
export type CommandShape = {
|
||||
command: string
|
||||
description?: string
|
||||
args: ArgShape[]
|
||||
execute: string | ((...args: any[]) => any)
|
||||
}
|
||||
|
||||
type ArgShape<T extends keyof ArgTypeMap = keyof ArgTypeMap> = {
|
||||
name: string
|
||||
type: T
|
||||
description?: string
|
||||
optional?: boolean
|
||||
default?: ArgTypeMap[T]
|
||||
}
|
||||
|
||||
type ArgTypeMap = {
|
||||
string: string
|
||||
number: number
|
||||
boolean: boolean
|
||||
}
|
||||
|
||||
const commandShapes: CommandShape[] = [
|
||||
{
|
||||
command: 'ls',
|
||||
description: 'List the contents of a directory',
|
||||
execute: './commands/ls.ts',
|
||||
args: [
|
||||
{ name: 'path', type: 'string', description: 'The path to list' },
|
||||
{ name: 'all', type: 'boolean', description: 'Show hidden files', default: false },
|
||||
{ name: 'long', type: 'boolean', description: 'List in long format', default: false },
|
||||
{
|
||||
name: 'short-names',
|
||||
type: 'boolean',
|
||||
description: 'Only print file names',
|
||||
default: false,
|
||||
},
|
||||
{ name: 'full-paths', type: 'boolean', description: 'Display full paths', default: false },
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
command: 'cd',
|
||||
description: 'Change the current working directory',
|
||||
execute: './commands/cd.ts',
|
||||
args: [{ name: 'path', type: 'string', description: 'The path to change to' }],
|
||||
},
|
||||
|
||||
{
|
||||
command: 'cp',
|
||||
description: 'Copy files or directories',
|
||||
execute: './commands/cp.ts',
|
||||
args: [
|
||||
{ name: 'source', type: 'string', description: 'Source file or directory' },
|
||||
{ name: 'destination', type: 'string', description: 'Destination path' },
|
||||
{ name: 'recursive', type: 'boolean', description: 'Copy recursively', default: false },
|
||||
{ name: 'verbose', type: 'boolean', description: 'Verbose output', default: false },
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
command: 'mv',
|
||||
description: 'Move files or directories',
|
||||
execute: './commands/mv.ts',
|
||||
args: [
|
||||
{ name: 'source', type: 'string', description: 'Source file or directory' },
|
||||
{ name: 'destination', type: 'string', description: 'Destination path' },
|
||||
{ name: 'verbose', type: 'boolean', description: 'Verbose output', default: false },
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
command: 'rm',
|
||||
description: 'Remove files or directories',
|
||||
execute: './commands/rm.ts',
|
||||
args: [
|
||||
{ name: 'path', type: 'string', description: 'Path to remove' },
|
||||
{ name: 'recursive', type: 'boolean', description: 'Remove recursively', default: false },
|
||||
{ name: 'force', type: 'boolean', description: 'Force removal', default: false },
|
||||
{ name: 'verbose', type: 'boolean', description: 'Verbose output', default: false },
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
command: 'mkdir',
|
||||
description: 'Create directories',
|
||||
execute: './commands/mkdir.ts',
|
||||
args: [
|
||||
{ name: 'path', type: 'string', description: 'Directory path to create' },
|
||||
{ name: 'verbose', type: 'boolean', description: 'Verbose output', default: false },
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
command: 'touch',
|
||||
description: 'Create empty files or update timestamps',
|
||||
execute: './commands/touch.ts',
|
||||
args: [
|
||||
{ name: 'path', type: 'string', description: 'File path to touch' },
|
||||
{ name: 'access', type: 'boolean', description: 'Update access time only', default: false },
|
||||
{
|
||||
name: 'modified',
|
||||
type: 'boolean',
|
||||
description: 'Update modified time only',
|
||||
default: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
command: 'echo',
|
||||
description: 'Display a string',
|
||||
execute: './commands/echo.ts',
|
||||
args: [
|
||||
{ name: 'text', type: 'string', description: 'Text to display' },
|
||||
{ name: 'no-newline', type: 'boolean', description: "Don't append newline", default: false },
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
command: 'cat',
|
||||
description: 'Display file contents',
|
||||
execute: './commands/cat.ts',
|
||||
args: [
|
||||
{ name: 'path', type: 'string', description: 'File to display' },
|
||||
{ name: 'numbered', type: 'boolean', description: 'Show line numbers', default: false },
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
command: 'head',
|
||||
description: 'Show first lines of input',
|
||||
execute: './commands/head.ts',
|
||||
args: [
|
||||
{ name: 'path', type: 'string', description: 'File to read from' },
|
||||
{ name: 'lines', type: 'number', description: 'Number of lines', default: 10 },
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
command: 'tail',
|
||||
description: 'Show last lines of input',
|
||||
execute: './commands/tail.ts',
|
||||
args: [
|
||||
{ name: 'path', type: 'string', description: 'File to read from' },
|
||||
{ name: 'lines', type: 'number', description: 'Number of lines', default: 10 },
|
||||
{ name: 'follow', type: 'boolean', description: 'Follow file changes', default: false },
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
command: 'grep',
|
||||
description: 'Search for patterns in text',
|
||||
execute: './commands/grep.ts',
|
||||
args: [
|
||||
{ name: 'pattern', type: 'string', description: 'Pattern to search for' },
|
||||
{
|
||||
name: 'ignore-case',
|
||||
type: 'boolean',
|
||||
description: 'Case insensitive search',
|
||||
default: false,
|
||||
},
|
||||
{ name: 'invert-match', type: 'boolean', description: 'Invert match', default: false },
|
||||
{ name: 'line-number', type: 'boolean', description: 'Show line numbers', default: false },
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
command: 'sort',
|
||||
description: 'Sort input',
|
||||
execute: './commands/sort.ts',
|
||||
args: [
|
||||
{ name: 'reverse', type: 'boolean', description: 'Sort in reverse order', default: false },
|
||||
{
|
||||
name: 'ignore-case',
|
||||
type: 'boolean',
|
||||
description: 'Case insensitive sort',
|
||||
default: false,
|
||||
},
|
||||
{ name: 'numeric', type: 'boolean', description: 'Numeric sort', default: false },
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
command: 'uniq',
|
||||
description: 'Filter out repeated lines',
|
||||
execute: './commands/uniq.ts',
|
||||
args: [
|
||||
{ name: 'count', type: 'boolean', description: 'Show count of occurrences', default: false },
|
||||
{
|
||||
name: 'repeated',
|
||||
type: 'boolean',
|
||||
description: 'Show only repeated lines',
|
||||
default: false,
|
||||
},
|
||||
{ name: 'unique', type: 'boolean', description: 'Show only unique lines', default: false },
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
command: 'select',
|
||||
description: 'Select specific columns from data',
|
||||
execute: './commands/select.ts',
|
||||
args: [{ name: 'columns', type: 'string', description: 'Columns to select' }],
|
||||
},
|
||||
|
||||
{
|
||||
command: 'where',
|
||||
description: 'Filter data based on conditions',
|
||||
execute: './commands/where.ts',
|
||||
args: [{ name: 'condition', type: 'string', description: 'Filter condition' }],
|
||||
},
|
||||
|
||||
{
|
||||
command: 'group-by',
|
||||
description: 'Group data by column values',
|
||||
execute: './commands/group-by.ts',
|
||||
args: [{ name: 'column', type: 'string', description: 'Column to group by' }],
|
||||
},
|
||||
|
||||
{
|
||||
command: 'ps',
|
||||
description: 'List running processes',
|
||||
execute: './commands/ps.ts',
|
||||
args: [
|
||||
{ name: 'long', type: 'boolean', description: 'Show detailed information', default: false },
|
||||
],
|
||||
},
|
||||
|
||||
{
|
||||
command: 'sys',
|
||||
description: 'Show system information',
|
||||
execute: './commands/sys.ts',
|
||||
args: [],
|
||||
},
|
||||
|
||||
{
|
||||
command: 'which',
|
||||
description: 'Find the location of a command',
|
||||
execute: './commands/which.ts',
|
||||
args: [
|
||||
{ name: 'command', type: 'string', description: 'Command to locate' },
|
||||
{ name: 'all', type: 'boolean', description: 'Show all matches', default: false },
|
||||
],
|
||||
},
|
||||
] as const
|
||||
|
||||
let commandSource = () => commandShapes
|
||||
export const setCommandSource = (fn: () => CommandShape[]) => {
|
||||
commandSource = fn
|
||||
}
|
||||
|
||||
export const resetCommandSource = () => {
|
||||
commandSource = () => commandShapes
|
||||
}
|
||||
|
||||
export const matchingCommands = (prefix: string) => {
|
||||
const match = commandSource().find((cmd) => cmd.command === prefix)
|
||||
const partialMatches = commandSource().filter((cmd) => cmd.command.startsWith(prefix))
|
||||
|
||||
return { match, partialMatches }
|
||||
}
|
||||
87
src/editor/completions.ts
Normal file
87
src/editor/completions.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { autocompletion, type CompletionContext, type Completion } from '@codemirror/autocomplete'
|
||||
import { Shrimp } from '#/index'
|
||||
|
||||
const keywords = [
|
||||
'import',
|
||||
'end',
|
||||
'do',
|
||||
'if',
|
||||
'else',
|
||||
'while',
|
||||
'try',
|
||||
'catch',
|
||||
'finally',
|
||||
'throw',
|
||||
'not',
|
||||
'and',
|
||||
'or',
|
||||
'true',
|
||||
'false',
|
||||
'null',
|
||||
]
|
||||
|
||||
const keywordCompletions: Completion[] = keywords.map((k) => ({
|
||||
label: k,
|
||||
type: 'keyword',
|
||||
boost: -1,
|
||||
}))
|
||||
|
||||
export const createShrimpCompletions = (shrimp: Shrimp) => {
|
||||
// Build completions from all names in the shrimp scope
|
||||
const scopeNames = shrimp.vm.scope.vars()
|
||||
console.log(`🌭`, shrimp.vm.vars())
|
||||
|
||||
const functionCompletions: Completion[] = scopeNames.map((name) => {
|
||||
const value = shrimp.vm.scope.get(name)
|
||||
let type
|
||||
if (value?.type === 'function' || value?.type === 'native') {
|
||||
type = 'function'
|
||||
} else if (value?.type === 'dict') {
|
||||
type = 'namespace'
|
||||
} else {
|
||||
type = 'variable'
|
||||
}
|
||||
|
||||
return { type, label: name }
|
||||
})
|
||||
|
||||
const allCompletions = [...keywordCompletions, ...functionCompletions]
|
||||
|
||||
// Get methods for a module (e.g., math, str, list)
|
||||
const getModuleMethods = (moduleName: string): Completion[] => {
|
||||
const module = shrimp.get(moduleName)
|
||||
if (!module || typeof module !== 'object') return []
|
||||
|
||||
return Object.keys(module).map((m) => ({ label: m, type: 'function' }))
|
||||
}
|
||||
|
||||
const shrimpCompletionSource = (context: CompletionContext) => {
|
||||
// Check for module.method pattern (e.g., "math.")
|
||||
const dotMatch = context.matchBefore(/[\w\-]+\.[\w\-\?]*/)
|
||||
if (dotMatch) {
|
||||
const [moduleName, methodPrefix] = dotMatch.text.split('.')
|
||||
const methods = getModuleMethods(moduleName!)
|
||||
|
||||
if (methods.length > 0) {
|
||||
const dotPos = dotMatch.from + moduleName!.length + 1
|
||||
return {
|
||||
from: dotPos,
|
||||
options: methods.filter((m) => m.label.startsWith(methodPrefix ?? '')),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Regular completions
|
||||
const word = context.matchBefore(/[\w\-\?\$]+/)
|
||||
if (!word || (word.from === word.to && !context.explicit)) return null
|
||||
|
||||
return {
|
||||
from: word.from,
|
||||
options: allCompletions.filter((c) => c.label.startsWith(word.text)),
|
||||
}
|
||||
}
|
||||
|
||||
return autocompletion({
|
||||
override: [shrimpCompletionSource],
|
||||
})
|
||||
}
|
||||
24
src/editor/diagnostics.ts
Normal file
24
src/editor/diagnostics.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { linter, type Diagnostic } from '@codemirror/lint'
|
||||
import { Shrimp } from '#/index'
|
||||
import { CompilerError } from '#compiler/compilerError'
|
||||
|
||||
export const createShrimpDiagnostics = (shrimp: Shrimp) =>
|
||||
linter((view) => {
|
||||
const code = view.state.doc.toString()
|
||||
const diagnostics: Diagnostic[] = []
|
||||
|
||||
try {
|
||||
shrimp.parse(code)
|
||||
} catch (err) {
|
||||
if (err instanceof CompilerError) {
|
||||
diagnostics.push({
|
||||
from: err.from,
|
||||
to: err.to,
|
||||
severity: 'error',
|
||||
message: err.message,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return diagnostics
|
||||
})
|
||||
|
|
@ -1,53 +1,78 @@
|
|||
#output {
|
||||
flex: 1;
|
||||
background: var(--bg-output);
|
||||
color: var(--text-output);
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
:root {
|
||||
/* Background colors */
|
||||
--bg-editor: #011627;
|
||||
--bg-output: #40318D;
|
||||
--bg-status-bar: #1E2A4A;
|
||||
--bg-status-border: #0E1A3A;
|
||||
--bg-selection: #1D3B53;
|
||||
|
||||
/* Text colors */
|
||||
--text-editor: #D6DEEB;
|
||||
--text-output: #7C70DA;
|
||||
--text-status: #B3A9FF55;
|
||||
--caret: #80A4C2;
|
||||
|
||||
/* Syntax highlighting colors */
|
||||
--color-keyword: #C792EA;
|
||||
--color-function: #82AAFF;
|
||||
--color-string: #C3E88D;
|
||||
--color-number: #F78C6C;
|
||||
--color-bool: #FF5370;
|
||||
--color-operator: #89DDFF;
|
||||
--color-paren: #676E95;
|
||||
--color-function-call: #FF9CAC;
|
||||
--color-variable-def: #FFCB6B;
|
||||
--color-error: #FF6E6E;
|
||||
--color-regex: #E1ACFF;
|
||||
|
||||
/* ANSI terminal colors */
|
||||
--ansi-black: #011627;
|
||||
--ansi-red: #FF5370;
|
||||
--ansi-green: #C3E88D;
|
||||
--ansi-yellow: #FFCB6B;
|
||||
--ansi-blue: #82AAFF;
|
||||
--ansi-magenta: #C792EA;
|
||||
--ansi-cyan: #89DDFF;
|
||||
--ansi-white: #D6DEEB;
|
||||
|
||||
/* ANSI bright colors (slightly more vibrant) */
|
||||
--ansi-bright-black: #676E95;
|
||||
--ansi-bright-red: #FF6E90;
|
||||
--ansi-bright-green: #D4F6A8;
|
||||
--ansi-bright-yellow: #FFE082;
|
||||
--ansi-bright-blue: #A8C7FA;
|
||||
--ansi-bright-magenta: #E1ACFF;
|
||||
--ansi-bright-cyan: #A8F5FF;
|
||||
--ansi-bright-white: #FFFFFF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'C64ProMono';
|
||||
src: url('../../assets/C64_Pro_Mono-STYLE.woff2') format('woff2');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Pixeloid Mono';
|
||||
src: url('../../assets/PixeloidMono.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--bg-editor);
|
||||
font-family: 'Pixeloid Mono', 'Courier New', monospace;
|
||||
font-size: 18px;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
#status-bar {
|
||||
height: 30px;
|
||||
background: var(--bg-status-bar);
|
||||
color: var(--text-status);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 10px;
|
||||
font-size: 14px;
|
||||
border-top: 3px solid var(--bg-status-border);
|
||||
border-bottom: 3px solid var(--bg-status-border);
|
||||
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
#status-bar .left,
|
||||
#status-bar .right {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
#status-bar .multiline {
|
||||
display: flex;
|
||||
|
||||
.dot {
|
||||
padding-top: 1px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.active {
|
||||
color: var(--color-string);
|
||||
}
|
||||
|
||||
.inactive {
|
||||
color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
.syntax-error {
|
||||
text-decoration: underline dotted var(--color-error);
|
||||
}
|
||||
#root {
|
||||
height: 100vh;
|
||||
}
|
||||
|
|
@ -1,140 +1,70 @@
|
|||
import { EditorView } from '@codemirror/view'
|
||||
import { asciiEscapeToHtml, assertNever, log, toElement } from '#utils/utils'
|
||||
import { Signal } from '#utils/signal'
|
||||
import { getContent } from '#editor/plugins/persistence'
|
||||
import type { HtmlEscapedString } from 'hono/utils/html'
|
||||
import { connectToNose, noseSignals } from '#editor/noseClient'
|
||||
import type { Value } from 'reefvm'
|
||||
import { Compartment } from '@codemirror/state'
|
||||
import { lineNumbers } from '@codemirror/view'
|
||||
import { shrimpSetup } from '#editor/plugins/shrimpSetup'
|
||||
import { EditorView, basicSetup } from 'codemirror'
|
||||
import { Shrimp } from '#/index'
|
||||
|
||||
import '#editor/editor.css'
|
||||
import { shrimpTheme } from './theme'
|
||||
import { createShrimpDiagnostics } from './diagnostics'
|
||||
import { shrimpHighlighter } from './highlighter'
|
||||
import { createShrimpCompletions } from './completions'
|
||||
import { shrimpKeymap } from './keymap'
|
||||
import { getContent, persistence } from './persistence'
|
||||
|
||||
const lineNumbersCompartment = new Compartment()
|
||||
type EditorProps = {
|
||||
initialCode?: string
|
||||
onChange?: (code: string) => void
|
||||
extensions?: import('@codemirror/state').Extension[]
|
||||
shrimp?: Shrimp
|
||||
}
|
||||
|
||||
connectToNose()
|
||||
|
||||
export const outputSignal = new Signal<Value | string>()
|
||||
export const errorSignal = new Signal<string>()
|
||||
export const multilineModeSignal = new Signal<boolean>()
|
||||
|
||||
export const Editor = () => {
|
||||
export const Editor = ({
|
||||
initialCode = '',
|
||||
onChange,
|
||||
extensions: customExtensions = [],
|
||||
shrimp = new Shrimp(),
|
||||
}: EditorProps) => {
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={(ref: Element) => {
|
||||
if (ref?.querySelector('.cm-editor')) return
|
||||
const view = new EditorView({
|
||||
parent: ref,
|
||||
doc: getContent(),
|
||||
extensions: shrimpSetup(lineNumbersCompartment),
|
||||
})
|
||||
|
||||
multilineModeSignal.connect((isMultiline) => {
|
||||
view.dispatch({
|
||||
effects: lineNumbersCompartment.reconfigure(isMultiline ? lineNumbers() : []),
|
||||
})
|
||||
})
|
||||
|
||||
requestAnimationFrame(() => view.focus())
|
||||
}}
|
||||
/>
|
||||
<div id="status-bar">
|
||||
<div className="left"></div>
|
||||
<div className="right"></div>
|
||||
</div>
|
||||
<div id="output"></div>
|
||||
<div id="error"></div>
|
||||
</>
|
||||
<div
|
||||
ref={(el: Element) => {
|
||||
if (!el?.querySelector('.cm-editor'))
|
||||
createEditorView(el, getContent() ?? initialCode, onChange, customExtensions, shrimp)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
noseSignals.connect((message) => {
|
||||
if (message.type === 'error') {
|
||||
log.error(`Nose error: ${message.data}`)
|
||||
errorSignal.emit(`Nose error: ${message.data}`)
|
||||
} else if (message.type === 'reef-output') {
|
||||
const x = outputSignal.emit(message.data)
|
||||
} else if (message.type === 'connected') {
|
||||
outputSignal.emit(`╞ Connected to Nose VM`)
|
||||
const createEditorView = (
|
||||
el: Element,
|
||||
initialCode: string,
|
||||
onChange: ((code: string) => void) | undefined,
|
||||
customExtensions: import('@codemirror/state').Extension[],
|
||||
shrimp: Shrimp
|
||||
) => {
|
||||
const extensions = [
|
||||
basicSetup,
|
||||
shrimpTheme,
|
||||
createShrimpDiagnostics(shrimp),
|
||||
createShrimpCompletions(shrimp),
|
||||
shrimpHighlighter,
|
||||
shrimpKeymap,
|
||||
persistence,
|
||||
...customExtensions,
|
||||
]
|
||||
|
||||
if (onChange) {
|
||||
extensions.push(
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
onChange(update.state.doc.toString())
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
outputSignal.connect((value) => {
|
||||
const el = document.querySelector('#output')!
|
||||
el.innerHTML = ''
|
||||
el.innerHTML = asciiEscapeToHtml(valueToString(value))
|
||||
})
|
||||
|
||||
errorSignal.connect((error) => {
|
||||
const el = document.querySelector('#output')!
|
||||
el.innerHTML = ''
|
||||
el.classList.add('error')
|
||||
el.innerHTML = asciiEscapeToHtml(error)
|
||||
})
|
||||
|
||||
type StatusBarMessage = {
|
||||
side: 'left' | 'right'
|
||||
message: string | Promise<HtmlEscapedString>
|
||||
className: string
|
||||
order?: number
|
||||
}
|
||||
export const statusBarSignal = new Signal<StatusBarMessage>()
|
||||
statusBarSignal.connect(async ({ side, message, className, order }) => {
|
||||
document.querySelector(`#status-bar .${className}`)?.remove()
|
||||
|
||||
const sideEl = document.querySelector(`#status-bar .${side}`)!
|
||||
const messageEl = (
|
||||
<div data-order={order ?? 0} className={className}>
|
||||
{await message}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Now go through the nodes and put it in the right spot based on order. Higher number means further right
|
||||
const nodes = Array.from(sideEl.childNodes)
|
||||
const index = nodes.findIndex((node) => {
|
||||
if (!(node instanceof HTMLElement)) return false
|
||||
return Number(node.dataset.order) > (order ?? 0)
|
||||
new EditorView({
|
||||
parent: el,
|
||||
doc: initialCode,
|
||||
extensions,
|
||||
})
|
||||
|
||||
if (index === -1) {
|
||||
sideEl.appendChild(toElement(messageEl))
|
||||
} else {
|
||||
sideEl.insertBefore(toElement(messageEl), nodes[index]!)
|
||||
}
|
||||
})
|
||||
|
||||
const valueToString = (value: Value | string): string => {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
|
||||
switch (value.type) {
|
||||
case 'null':
|
||||
return 'null'
|
||||
case 'boolean':
|
||||
return value.value ? 'true' : 'false'
|
||||
case 'number':
|
||||
return value.value.toString()
|
||||
case 'string':
|
||||
return value.value
|
||||
case 'array':
|
||||
return `${value.value.map(valueToString).join('\n')}`
|
||||
case 'dict': {
|
||||
const entries = Array.from(value.value.entries()).map(
|
||||
([key, val]) => `"${key}": ${valueToString(val)}`,
|
||||
)
|
||||
return `{${entries.join(', ')}}`
|
||||
}
|
||||
case 'regex':
|
||||
return `/${value.value.source}/`
|
||||
case 'function':
|
||||
return `<function>`
|
||||
case 'native':
|
||||
return `<function ${value.fn.name}>`
|
||||
default:
|
||||
assertNever(value)
|
||||
return `<unknown value type: ${(value as any).type}>`
|
||||
}
|
||||
// Trigger onChange with initial content
|
||||
onChange?.(initialCode)
|
||||
}
|
||||
|
|
|
|||
6
src/editor/example/frontend.tsx
Normal file
6
src/editor/example/frontend.tsx
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { Editor } from '#editor/editor'
|
||||
import { render } from 'hono/jsx/dom'
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
render(<Editor initialCode={'# type some code'} />, document.getElementById('root')!)
|
||||
}
|
||||
11
src/editor/example/index.html
Normal file
11
src/editor/example/index.html
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="stylesheet" href="../editor.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./frontend.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
14
src/editor/example/server.tsx
Normal file
14
src/editor/example/server.tsx
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import index from './index.html'
|
||||
|
||||
const server = Bun.serve({
|
||||
port: 3000,
|
||||
routes: {
|
||||
'/': index,
|
||||
},
|
||||
development: {
|
||||
hmr: true,
|
||||
console: true,
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`Editor running at ${server.url}`)
|
||||
73
src/editor/highlighter.ts
Normal file
73
src/editor/highlighter.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { EditorView, Decoration, DecorationSet, ViewPlugin, ViewUpdate } from '@codemirror/view'
|
||||
import { RangeSetBuilder } from '@codemirror/state'
|
||||
import { Shrimp } from '#/index'
|
||||
import { type SyntaxNode } from '#parser/node'
|
||||
import { log } from '#utils/utils'
|
||||
|
||||
const shrimp = new Shrimp()
|
||||
|
||||
export const shrimpHighlighter = ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = this.highlight(view)
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (!update.docChanged) return
|
||||
|
||||
this.decorations = this.highlight(update.view)
|
||||
}
|
||||
|
||||
highlight(view: EditorView): DecorationSet {
|
||||
const builder = new RangeSetBuilder<Decoration>()
|
||||
const code = view.state.doc.toString()
|
||||
|
||||
try {
|
||||
const tree = shrimp.parse(code)
|
||||
const decorations: { from: number; to: number; class: string }[] = []
|
||||
|
||||
tree.iterate({
|
||||
enter: (node) => {
|
||||
const cls = tokenStyles[node.type.name]
|
||||
const isLeaf = node.children.length === 0
|
||||
if (cls && isLeaf) {
|
||||
decorations.push({ from: node.from, to: node.to, class: cls })
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Sort by position (required by RangeSetBuilder)
|
||||
decorations.sort((a, b) => a.from - b.from)
|
||||
|
||||
for (const d of decorations) {
|
||||
builder.add(d.from, d.to, Decoration.mark({ class: d.class }))
|
||||
}
|
||||
} catch (error) {
|
||||
log('Parsing error in highlighter', error)
|
||||
}
|
||||
|
||||
return builder.finish()
|
||||
}
|
||||
},
|
||||
{
|
||||
decorations: (v) => v.decorations,
|
||||
}
|
||||
)
|
||||
|
||||
// Map node types to CSS classes
|
||||
const tokenStyles: Record<string, string> = {
|
||||
keyword: 'tok-keyword',
|
||||
String: 'tok-string',
|
||||
StringFragment: 'tok-string',
|
||||
CurlyString: 'tok-string',
|
||||
Number: 'tok-number',
|
||||
Boolean: 'tok-bool',
|
||||
Null: 'tok-null',
|
||||
Identifier: 'tok-identifier',
|
||||
AssignableIdentifier: 'tok-variable-def',
|
||||
Comment: 'tok-comment',
|
||||
operator: 'tok-operator',
|
||||
Regex: 'tok-regex',
|
||||
}
|
||||
1
src/editor/index.ts
Normal file
1
src/editor/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { Editor } from './editor'
|
||||
8
src/editor/keymap.ts
Normal file
8
src/editor/keymap.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { keymap } from '@codemirror/view'
|
||||
import { acceptCompletion } from '@codemirror/autocomplete'
|
||||
import { indentWithTab } from '@codemirror/commands'
|
||||
|
||||
export const shrimpKeymap = keymap.of([
|
||||
{ key: 'Tab', run: acceptCompletion },
|
||||
indentWithTab,
|
||||
])
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
import { Signal } from '#utils/signal'
|
||||
import type { Bytecode, Value } from 'reefvm'
|
||||
let ws: WebSocket
|
||||
|
||||
type IncomingMessage =
|
||||
| { type: 'connected' }
|
||||
| { type: 'ping'; data: number }
|
||||
| { type: 'commands'; data: number }
|
||||
| {
|
||||
type: 'apps'
|
||||
data: {
|
||||
name: string
|
||||
type: 'browser' | 'server'
|
||||
}[]
|
||||
}
|
||||
| {
|
||||
type: 'session:start'
|
||||
data: {
|
||||
NOSE_DIR: string
|
||||
cwd: string
|
||||
hostname: string
|
||||
mode: string
|
||||
project: string
|
||||
}
|
||||
}
|
||||
| { type: 'reef-output'; data: Value }
|
||||
| { type: 'error'; data: string }
|
||||
|
||||
export const noseSignals = new Signal<IncomingMessage>()
|
||||
|
||||
export const connectToNose = (url: string = 'ws://localhost:3000/ws') => {
|
||||
ws = new WebSocket(url)
|
||||
ws.onopen = () => noseSignals.emit({ type: 'connected' })
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data)
|
||||
noseSignals.emit(message)
|
||||
}
|
||||
|
||||
ws.onerror = (event) => {
|
||||
console.error(`💥WebSocket error:`, event)
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
console.log(`🚪 Connection closed`)
|
||||
}
|
||||
}
|
||||
|
||||
let id = 0
|
||||
export const sendToNose = (code: Bytecode) => {
|
||||
if (!ws) {
|
||||
throw new Error('WebSocket is not connected.')
|
||||
} else if (ws.readyState !== WebSocket.OPEN) {
|
||||
throw new Error(`WebSocket is not open, current status is ${ws.readyState}.`)
|
||||
}
|
||||
|
||||
id += 1
|
||||
ws.send(JSON.stringify({ type: 'reef-bytecode', data: code, id }))
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { ViewPlugin, ViewUpdate } from '@codemirror/view'
|
||||
|
||||
export const persistencePlugin = ViewPlugin.fromClass(
|
||||
export const persistence = ViewPlugin.fromClass(
|
||||
class {
|
||||
saveTimeout?: ReturnType<typeof setTimeout>
|
||||
|
||||
|
|
@ -17,13 +17,13 @@ export const persistencePlugin = ViewPlugin.fromClass(
|
|||
destroy() {
|
||||
if (this.saveTimeout) clearTimeout(this.saveTimeout)
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export const getContent = () => {
|
||||
return localStorage.getItem('shrimp-editor-content') || ''
|
||||
}
|
||||
|
||||
const setContent = (data: string) => {
|
||||
export const setContent = (data: string) => {
|
||||
localStorage.setItem('shrimp-editor-content', data)
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import { errorSignal } from '#editor/editor'
|
||||
import { EditorView } from '@codemirror/view'
|
||||
|
||||
export const catchErrors = EditorView.exceptionSink.of((exception) => {
|
||||
console.error('CodeMirror error:', exception)
|
||||
errorSignal.emit(
|
||||
`Editor error: ${exception instanceof Error ? exception.message : String(exception)}`,
|
||||
)
|
||||
})
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import { EditorView, ViewPlugin, ViewUpdate } from '@codemirror/view'
|
||||
import { syntaxTree } from '@codemirror/language'
|
||||
import { statusBarSignal } from '#editor/editor'
|
||||
|
||||
export const debugTags = ViewPlugin.fromClass(
|
||||
class {
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged || update.selectionSet || update.geometryChanged) {
|
||||
this.updateStatusBar(update.view)
|
||||
}
|
||||
}
|
||||
|
||||
updateStatusBar(view: EditorView) {
|
||||
const pos = view.state.selection.main.head + 1
|
||||
const tree = syntaxTree(view.state)
|
||||
|
||||
let tags: string[] = []
|
||||
let node = tree.resolveInner(pos, -1)
|
||||
|
||||
while (node) {
|
||||
tags.push(node.type.name)
|
||||
node = node.parent!
|
||||
if (!node) break
|
||||
}
|
||||
|
||||
const debugText = tags.length ? tags.reverse().slice(1).join(' > ') : 'No nodes'
|
||||
statusBarSignal.emit({
|
||||
side: 'right',
|
||||
message: debugText,
|
||||
className: 'debug-tags',
|
||||
order: -1,
|
||||
})
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
import { parser } from '#parser/shrimp'
|
||||
import type { Timeout } from '#utils/utils'
|
||||
import { Range } from '@codemirror/state'
|
||||
import {
|
||||
Decoration,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
ViewUpdate,
|
||||
type DecorationSet,
|
||||
} from '@codemirror/view'
|
||||
|
||||
export const shrimpErrors = ViewPlugin.fromClass(
|
||||
class {
|
||||
timeout?: Timeout
|
||||
decorations: DecorationSet = Decoration.none
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.updateErrors(view)
|
||||
}
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (update.docChanged) {
|
||||
this.debounceUpdate(update.view)
|
||||
}
|
||||
}
|
||||
|
||||
updateErrors(view: EditorView) {
|
||||
this.decorations = Decoration.none
|
||||
try {
|
||||
const decorations: Range<Decoration>[] = []
|
||||
const tree = parser.parse(view.state.doc.toString())
|
||||
tree.iterate({
|
||||
enter: (node) => {
|
||||
if (!node.type.isError) return
|
||||
|
||||
// Skip empty error nodes
|
||||
if (node.from === node.to) return
|
||||
|
||||
const decoration = Decoration.mark({
|
||||
class: 'syntax-error',
|
||||
attributes: { title: 'COREY REPLACE THIS' },
|
||||
}).range(node.from, node.to)
|
||||
decorations.push(decoration)
|
||||
},
|
||||
})
|
||||
|
||||
this.decorations = Decoration.set(decorations)
|
||||
// requestAnimationFrame(() => view.dispatch({}))
|
||||
} catch (e) {
|
||||
console.error('🙈 Error parsing document', e)
|
||||
}
|
||||
}
|
||||
|
||||
debounceUpdate = (view: EditorView) => {
|
||||
clearTimeout(this.timeout)
|
||||
this.timeout = setTimeout(() => this.updateErrors(view), 250)
|
||||
}
|
||||
},
|
||||
{
|
||||
decorations: (v) => v.decorations,
|
||||
},
|
||||
)
|
||||
|
|
@ -1,232 +0,0 @@
|
|||
import {
|
||||
ViewPlugin,
|
||||
ViewUpdate,
|
||||
EditorView,
|
||||
Decoration,
|
||||
type DecorationSet,
|
||||
} from '@codemirror/view'
|
||||
import { syntaxTree } from '@codemirror/language'
|
||||
import { type SyntaxNode } from '@lezer/common'
|
||||
import { WidgetType } from '@codemirror/view'
|
||||
import { toElement } from '#utils/utils'
|
||||
import { matchingCommands } from '#editor/commands'
|
||||
import * as Terms from '#parser/shrimp.terms'
|
||||
|
||||
const ghostTextTheme = EditorView.theme({
|
||||
'.ghost-text': {
|
||||
color: '#666',
|
||||
opacity: '0.6',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
})
|
||||
|
||||
type Hint = { cursor: number; hintText?: string; completionText?: string }
|
||||
|
||||
export const inlineHints = [
|
||||
ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet = Decoration.none
|
||||
currentHint?: Hint
|
||||
|
||||
update(update: ViewUpdate) {
|
||||
if (!update.docChanged && !update.selectionSet) return
|
||||
|
||||
this.clearHints()
|
||||
let hint = this.getContext(update.view)
|
||||
this.currentHint = hint
|
||||
this.showHint(hint)
|
||||
}
|
||||
|
||||
handleTab(view: EditorView) {
|
||||
if (!this.currentHint?.completionText) return false
|
||||
|
||||
this.decorations = Decoration.none
|
||||
view.dispatch({
|
||||
changes: {
|
||||
from: this.currentHint.cursor,
|
||||
insert: this.currentHint.completionText,
|
||||
},
|
||||
selection: {
|
||||
anchor: this.currentHint.cursor + this.currentHint.completionText.length,
|
||||
},
|
||||
})
|
||||
this.currentHint = undefined
|
||||
return true
|
||||
}
|
||||
|
||||
clearHints() {
|
||||
this.currentHint = undefined
|
||||
this.decorations = Decoration.none
|
||||
}
|
||||
|
||||
getContext(view: EditorView): Hint {
|
||||
const cursor = view.state.selection.main.head
|
||||
|
||||
const isCursorAtEnd = cursor === view.state.doc.length
|
||||
if (!isCursorAtEnd) return { cursor }
|
||||
|
||||
const token = this.getCommandContextToken(view, cursor)
|
||||
if (!token) return { cursor }
|
||||
|
||||
const text = view.state.doc.sliceString(token.from, token.to)
|
||||
const tokenId = token.type.id
|
||||
|
||||
let completionText = ''
|
||||
let hintText = ''
|
||||
const justSpaces = view.state.doc.sliceString(cursor - 1, cursor) === ' '
|
||||
|
||||
if (tokenId === Terms.CommandPartial) {
|
||||
const { partialMatches } = matchingCommands(text)
|
||||
const match = partialMatches[0]
|
||||
if (match) {
|
||||
completionText = match.command.slice(text.length) + ' '
|
||||
hintText = completionText
|
||||
}
|
||||
} else if (
|
||||
tokenId === Terms.Identifier &&
|
||||
token.parent?.type.id === Terms.Arg &&
|
||||
!justSpaces
|
||||
) {
|
||||
const { availableArgs } = this.getCommandContext(view, token)
|
||||
const matchingArgs = availableArgs.filter((arg) => arg.name.startsWith(text))
|
||||
const match = matchingArgs[0]
|
||||
if (match) {
|
||||
hintText = `${match.name.slice(text.length)}=<${match.type}>`
|
||||
completionText = `${match.name.slice(text.length)}=`
|
||||
}
|
||||
} else if (this.containedBy(token, Terms.PartialNamedArg)) {
|
||||
const { availableArgs } = this.getCommandContext(view, token)
|
||||
const textWithoutEquals = text.slice(0, -1)
|
||||
const matchingArgs = availableArgs.filter((arg) => arg.name == textWithoutEquals)
|
||||
const match = matchingArgs[0]
|
||||
if (match) {
|
||||
hintText = `<${match.type}>`
|
||||
completionText = 'default' in match ? `${match.default}` : ''
|
||||
}
|
||||
} else {
|
||||
const { availableArgs } = this.getCommandContext(view, token)
|
||||
const nextArg = Array.from(availableArgs)[0]
|
||||
const space = justSpaces ? '' : ' '
|
||||
if (nextArg) {
|
||||
hintText = `${space}${nextArg.name}=<${nextArg.type}>`
|
||||
if (nextArg) {
|
||||
completionText = `${space}${nextArg.name}=`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { completionText, hintText, cursor }
|
||||
}
|
||||
|
||||
getCommandContextToken(view: EditorView, cursor: number) {
|
||||
const tree = syntaxTree(view.state)
|
||||
let node = tree.resolveInner(cursor, -1)
|
||||
|
||||
// If we're in a CommandCall, return the token before cursor
|
||||
if (this.containedBy(node, Terms.CommandCall)) {
|
||||
return tree.resolveInner(cursor, -1)
|
||||
}
|
||||
|
||||
// If we're in Program, look backward
|
||||
while (node.name === 'Program' && cursor > 0) {
|
||||
cursor -= 1
|
||||
node = tree.resolveInner(cursor, -1)
|
||||
if (this.containedBy(node, Terms.CommandCall)) {
|
||||
return tree.resolveInner(cursor, -1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
containedBy(node: SyntaxNode, nodeId: number): SyntaxNode | undefined {
|
||||
let current: SyntaxNode | undefined = node
|
||||
|
||||
while (current) {
|
||||
if (current.type.id === nodeId) {
|
||||
return current
|
||||
}
|
||||
|
||||
current = current.parent ?? undefined
|
||||
}
|
||||
}
|
||||
|
||||
showHint(hint: Hint) {
|
||||
if (!hint.hintText) return
|
||||
|
||||
const widget = new GhostTextWidget(hint.hintText)
|
||||
const afterCursor = 1
|
||||
const decoration = Decoration.widget({ widget, side: afterCursor }).range(hint.cursor)
|
||||
this.decorations = Decoration.set([decoration])
|
||||
}
|
||||
|
||||
getCommandContext(view: EditorView, currentToken: SyntaxNode) {
|
||||
let commandCallNode = currentToken.parent
|
||||
while (commandCallNode?.type.name !== 'CommandCall') {
|
||||
if (!commandCallNode) {
|
||||
throw new Error('No CommandCall parent found, must be an error in the grammar')
|
||||
}
|
||||
commandCallNode = commandCallNode.parent
|
||||
}
|
||||
|
||||
const commandToken = commandCallNode.firstChild
|
||||
if (!commandToken) {
|
||||
throw new Error('CommandCall has no children, must be an error in the grammar')
|
||||
}
|
||||
|
||||
const commandText = view.state.doc.sliceString(commandToken.from, commandToken.to)
|
||||
const { match: commandShape } = matchingCommands(commandText)
|
||||
if (!commandShape) {
|
||||
throw new Error(`No command shape found for command "${commandText}"`)
|
||||
}
|
||||
|
||||
let availableArgs = [...commandShape.args]
|
||||
|
||||
// Walk through all NamedArg children
|
||||
let child = commandToken.nextSibling
|
||||
|
||||
while (child) {
|
||||
console.log('child', child.type.name, child.to - child.from)
|
||||
if (child.type.id === Terms.NamedArg) {
|
||||
const argName = child.firstChild // Should be the Identifier
|
||||
if (argName) {
|
||||
const argText = view.state.doc.sliceString(argName.from, argName.to - 1)
|
||||
availableArgs = availableArgs.filter((arg) => arg.name !== argText)
|
||||
}
|
||||
} else if (child.type.id == Terms.Arg) {
|
||||
const hasSpaceAfter = view.state.doc.sliceString(child.to, child.to + 1) === ' '
|
||||
if (hasSpaceAfter) {
|
||||
availableArgs.shift()
|
||||
}
|
||||
}
|
||||
|
||||
child = child.nextSibling
|
||||
}
|
||||
|
||||
return { commandShape, availableArgs }
|
||||
}
|
||||
},
|
||||
{
|
||||
decorations: (v) => v.decorations,
|
||||
eventHandlers: {
|
||||
keydown(event, view) {
|
||||
if (event.key === 'Tab') {
|
||||
event.preventDefault()
|
||||
const plugin = view.plugin(inlineHints[0]! as ViewPlugin<any>)
|
||||
plugin?.handleTab(view)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
ghostTextTheme,
|
||||
]
|
||||
|
||||
class GhostTextWidget extends WidgetType {
|
||||
constructor(private text: string) {
|
||||
super()
|
||||
}
|
||||
|
||||
toDOM() {
|
||||
const el = <span className="ghost-text">{this.text}</span>
|
||||
return toElement(el)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,184 +0,0 @@
|
|||
import { multilineModeSignal, outputSignal } from '#editor/editor'
|
||||
import { printBytecodeOutput, printParserOutput, runCode } from '#editor/runCode'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
import { keymap } from '@codemirror/view'
|
||||
|
||||
let multilineMode = false
|
||||
|
||||
const customKeymap = keymap.of([
|
||||
{
|
||||
key: 'Enter',
|
||||
run: (view) => {
|
||||
if (multilineMode) return false
|
||||
|
||||
const input = view.state.doc.toString()
|
||||
history.push(input)
|
||||
runCode(input)
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: '' },
|
||||
selection: { anchor: 0 },
|
||||
})
|
||||
return true
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
key: 'Shift-Enter',
|
||||
run: (view) => {
|
||||
if (multilineMode) {
|
||||
const input = view.state.doc.toString()
|
||||
runCode(input)
|
||||
|
||||
return true
|
||||
} else {
|
||||
outputSignal.emit('Press Shift+Enter to insert run the code.')
|
||||
}
|
||||
|
||||
multilineModeSignal.emit(true)
|
||||
multilineMode = true
|
||||
view.dispatch({
|
||||
changes: { from: view.state.doc.length, insert: '\n' },
|
||||
selection: { anchor: view.state.doc.length + 1 },
|
||||
})
|
||||
|
||||
return true
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
key: 'Tab',
|
||||
preventDefault: true,
|
||||
run: (view) => {
|
||||
view.dispatch({
|
||||
changes: { from: view.state.selection.main.from, insert: ' ' },
|
||||
selection: { anchor: view.state.selection.main.from + 2 },
|
||||
})
|
||||
return true
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
key: 'ArrowUp',
|
||||
run: (view) => {
|
||||
if (multilineMode) return false
|
||||
|
||||
const command = history.previous()
|
||||
if (command === undefined) return false
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: command },
|
||||
selection: { anchor: command.length },
|
||||
})
|
||||
return true
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
key: 'ArrowDown',
|
||||
run: (view) => {
|
||||
if (multilineMode) return false
|
||||
|
||||
const command = history.next()
|
||||
if (command === undefined) return false
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: command },
|
||||
selection: { anchor: command.length },
|
||||
})
|
||||
return true
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
key: 'Mod-k 1',
|
||||
preventDefault: true,
|
||||
run: (view) => {
|
||||
const input = view.state.doc.toString()
|
||||
printParserOutput(input)
|
||||
|
||||
return true
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
key: 'Mod-k 2',
|
||||
preventDefault: true,
|
||||
run: (view) => {
|
||||
const input = view.state.doc.toString()
|
||||
printBytecodeOutput(input)
|
||||
|
||||
return true
|
||||
},
|
||||
},
|
||||
])
|
||||
|
||||
let firstTime = true
|
||||
const singleLineFilter = EditorState.transactionFilter.of((transaction) => {
|
||||
if (multilineMode) return transaction // Allow everything in multiline mode
|
||||
|
||||
if (firstTime) {
|
||||
firstTime = false
|
||||
if (transaction.newDoc.toString().includes('\n')) {
|
||||
multilineMode = true
|
||||
return transaction
|
||||
}
|
||||
}
|
||||
|
||||
transaction.changes.iterChanges((fromA, toA, fromB, toB, inserted) => {
|
||||
if (inserted.toString().includes('\n')) {
|
||||
multilineMode = true
|
||||
return
|
||||
}
|
||||
})
|
||||
|
||||
return transaction
|
||||
})
|
||||
|
||||
export const shrimpKeymap = [customKeymap, singleLineFilter]
|
||||
|
||||
class History {
|
||||
private commands: string[] = []
|
||||
private index: number | undefined
|
||||
private storageKey = 'shrimp-command-history'
|
||||
|
||||
constructor() {
|
||||
try {
|
||||
this.commands = JSON.parse(localStorage.getItem(this.storageKey) || '[]')
|
||||
} catch {
|
||||
console.warn('Failed to load command history from localStorage')
|
||||
}
|
||||
}
|
||||
|
||||
push(command: string) {
|
||||
this.commands.push(command)
|
||||
|
||||
// Limit to last 50 commands
|
||||
this.commands = this.commands.slice(-50)
|
||||
localStorage.setItem(this.storageKey, JSON.stringify(this.commands))
|
||||
this.index = undefined
|
||||
}
|
||||
|
||||
previous(): string | undefined {
|
||||
if (this.commands.length === 0) return
|
||||
|
||||
if (this.index === undefined) {
|
||||
this.index = this.commands.length - 1
|
||||
} else if (this.index > 0) {
|
||||
this.index -= 1
|
||||
}
|
||||
|
||||
return this.commands[this.index]
|
||||
}
|
||||
|
||||
next(): string | undefined {
|
||||
if (this.commands.length === 0 || this.index === undefined) return
|
||||
|
||||
if (this.index < this.commands.length - 1) {
|
||||
this.index += 1
|
||||
return this.commands[this.index]
|
||||
} else {
|
||||
this.index = undefined
|
||||
return ''
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const history = new History()
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
import { parser } from '#/parser/shrimp'
|
||||
import { LRLanguage, LanguageSupport } from '@codemirror/language'
|
||||
import { highlighting } from '#/parser/highlight.js'
|
||||
|
||||
const language = LRLanguage.define({
|
||||
parser: parser.configure({ props: [highlighting] }),
|
||||
})
|
||||
|
||||
export const shrimpLanguage = new LanguageSupport(language)
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import { history, defaultKeymap, historyKeymap } from '@codemirror/commands'
|
||||
import { bracketMatching, indentOnInput } from '@codemirror/language'
|
||||
import { highlightSpecialChars, drawSelection, dropCursor, keymap } from '@codemirror/view'
|
||||
import { closeBrackets, autocompletion, completionKeymap } from '@codemirror/autocomplete'
|
||||
import { EditorState, Compartment } from '@codemirror/state'
|
||||
import { searchKeymap } from '@codemirror/search'
|
||||
import { shrimpKeymap } from './keymap'
|
||||
import { shrimpTheme, shrimpHighlighting } from './theme'
|
||||
import { shrimpLanguage } from './shrimpLanguage'
|
||||
import { shrimpErrors } from './errors'
|
||||
import { persistencePlugin } from './persistence'
|
||||
import { catchErrors } from './catchErrors'
|
||||
|
||||
export const shrimpSetup = (lineNumbersCompartment: Compartment) => {
|
||||
return [
|
||||
catchErrors,
|
||||
shrimpKeymap,
|
||||
highlightSpecialChars(),
|
||||
history(),
|
||||
drawSelection(),
|
||||
dropCursor(),
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
bracketMatching(),
|
||||
closeBrackets(),
|
||||
autocompletion(),
|
||||
indentOnInput(),
|
||||
keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap, ...completionKeymap]),
|
||||
lineNumbersCompartment.of([]),
|
||||
shrimpTheme,
|
||||
shrimpLanguage,
|
||||
shrimpHighlighting,
|
||||
shrimpErrors,
|
||||
persistencePlugin,
|
||||
]
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
import { EditorView } from '@codemirror/view'
|
||||
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
|
||||
import { tags } from '@lezer/highlight'
|
||||
|
||||
const highlightStyle = HighlightStyle.define([
|
||||
{ tag: tags.keyword, color: 'var(--color-keyword)' },
|
||||
{ tag: tags.name, color: 'var(--color-function)' },
|
||||
{ tag: tags.string, color: 'var(--color-string)' },
|
||||
{ tag: tags.number, color: 'var(--color-number)' },
|
||||
{ tag: tags.bool, color: 'var(--color-bool)' },
|
||||
{ tag: tags.operator, color: 'var(--color-operator)' },
|
||||
{ tag: tags.paren, color: 'var(--color-paren)' },
|
||||
{ tag: tags.regexp, color: 'var(--color-regex)' },
|
||||
{ tag: tags.function(tags.variableName), color: 'var(--color-function-call)' },
|
||||
{ tag: tags.function(tags.invalid), color: 'white' },
|
||||
{
|
||||
tag: tags.definition(tags.variableName),
|
||||
color: 'var(--color-variable-def)',
|
||||
backgroundColor: 'var(--bg-variable-def)',
|
||||
padding: '1px 2px',
|
||||
borderRadius: '2px',
|
||||
fontWeight: '500',
|
||||
},
|
||||
])
|
||||
|
||||
export const shrimpHighlighting = syntaxHighlighting(highlightStyle)
|
||||
|
||||
export const shrimpTheme = EditorView.theme(
|
||||
{
|
||||
'&': {
|
||||
color: 'var(--text-editor)',
|
||||
backgroundColor: 'var(--bg-editor)',
|
||||
height: '100%',
|
||||
fontSize: '18px',
|
||||
},
|
||||
'.cm-content': {
|
||||
fontFamily: '"Pixeloid Mono", "Courier New", monospace',
|
||||
caretColor: 'var(--caret)',
|
||||
padding: '0px',
|
||||
},
|
||||
'&.cm-focused .cm-cursor': {
|
||||
borderLeftColor: 'var(--caret)',
|
||||
},
|
||||
'&.cm-focused .cm-selectionBackground, ::selection': {
|
||||
backgroundColor: 'var(--bg-selection)',
|
||||
},
|
||||
'.cm-editor': {
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
height: '100%',
|
||||
},
|
||||
'.cm-matchingBracket': {
|
||||
backgroundColor: 'var(--color-bool)',
|
||||
},
|
||||
'.cm-nonmatchingBracket': {
|
||||
backgroundColor: 'var(--color-string)',
|
||||
},
|
||||
},
|
||||
{ dark: true },
|
||||
)
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import { outputSignal, errorSignal } from '#editor/editor'
|
||||
import { Compiler } from '#compiler/compiler'
|
||||
import { errorMessage, log } from '#utils/utils'
|
||||
import { bytecodeToString } from 'reefvm'
|
||||
import { parser } from '#parser/shrimp'
|
||||
import { sendToNose } from '#editor/noseClient'
|
||||
import { treeToString } from '#utils/tree'
|
||||
|
||||
export const runCode = async (input: string) => {
|
||||
try {
|
||||
const compiler = new Compiler(input)
|
||||
sendToNose(compiler.bytecode)
|
||||
} catch (error) {
|
||||
log.error(error)
|
||||
errorSignal.emit(`${errorMessage(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
export const printParserOutput = (input: string) => {
|
||||
try {
|
||||
const cst = parser.parse(input)
|
||||
const string = treeToString(cst, input)
|
||||
outputSignal.emit(string)
|
||||
} catch (error) {
|
||||
log.error(error)
|
||||
errorSignal.emit(`${errorMessage(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
export const printBytecodeOutput = (input: string) => {
|
||||
try {
|
||||
const compiler = new Compiler(input)
|
||||
outputSignal.emit(bytecodeToString(compiler.bytecode))
|
||||
} catch (error) {
|
||||
log.error(error)
|
||||
errorSignal.emit(`${errorMessage(error)}`)
|
||||
}
|
||||
}
|
||||
51
src/editor/theme.ts
Normal file
51
src/editor/theme.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { EditorView } from '@codemirror/view'
|
||||
|
||||
export const shrimpTheme = EditorView.theme(
|
||||
{
|
||||
'&': {
|
||||
color: 'var(--text-editor)',
|
||||
backgroundColor: 'var(--bg-editor)',
|
||||
height: '100%',
|
||||
fontSize: '18px',
|
||||
},
|
||||
'.cm-content': {
|
||||
fontFamily: '"Pixeloid Mono", "Courier New", monospace',
|
||||
caretColor: 'var(--caret)',
|
||||
padding: '0px',
|
||||
borderTop: '1px solid var(--bg-editor)',
|
||||
},
|
||||
'&.cm-focused .cm-cursor': {
|
||||
borderLeftColor: 'var(--caret)',
|
||||
},
|
||||
'.cm-selectionLayer .cm-selectionBackground': {
|
||||
backgroundColor: 'var(--bg-selection)',
|
||||
},
|
||||
'.cm-editor': {
|
||||
border: 'none',
|
||||
outline: 'none',
|
||||
height: '100%',
|
||||
},
|
||||
'.cm-matchingBracket': {
|
||||
backgroundColor: 'var(--color-bool)',
|
||||
},
|
||||
'.cm-nonmatchingBracket': {
|
||||
backgroundColor: 'var(--color-string)',
|
||||
},
|
||||
'.cm-activeLine': {
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.03)',
|
||||
},
|
||||
|
||||
// Token highlighting
|
||||
'.tok-keyword': { color: 'var(--color-keyword)' },
|
||||
'.tok-string': { color: 'var(--color-string)' },
|
||||
'.tok-number': { color: 'var(--color-number)' },
|
||||
'.tok-bool': { color: 'var(--color-bool)' },
|
||||
'.tok-null': { color: 'var(--color-number)', fontStyle: 'italic' },
|
||||
'.tok-identifier': { color: 'var(--color-function)' },
|
||||
'.tok-variable-def': { color: 'var(--color-variable-def)' },
|
||||
'.tok-comment': { color: 'var(--ansi-bright-black)', fontStyle: 'italic' },
|
||||
'.tok-operator': { color: 'var(--color-operator)' },
|
||||
'.tok-regex': { color: 'var(--color-regex)' },
|
||||
},
|
||||
{ dark: true }
|
||||
)
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
const DEBUG = process.env.DEBUG || false
|
||||
import { isDebug } from '#utils/utils'
|
||||
|
||||
export type Token = {
|
||||
type: TokenType
|
||||
|
|
@ -169,11 +169,11 @@ export class Scanner {
|
|||
from,
|
||||
to,
|
||||
},
|
||||
valueTokens.has(type) ? { value: this.input.slice(from, to) } : {},
|
||||
),
|
||||
valueTokens.has(type) ? { value: this.input.slice(from, to) } : {}
|
||||
)
|
||||
)
|
||||
|
||||
if (DEBUG) {
|
||||
if (isDebug()) {
|
||||
const tok = this.tokens.at(-1)
|
||||
console.log(`≫ PUSH(${from},${to})`, TokenType[tok?.type || 0], '—', tok?.value)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { list } from './list'
|
|||
import { math } from './math'
|
||||
import { str } from './str'
|
||||
import { types } from './types'
|
||||
import { runningInBrowser } from '#utils/utils'
|
||||
|
||||
export const globals: Record<string, any> = {
|
||||
date,
|
||||
|
|
@ -31,17 +32,28 @@ export const globals: Record<string, any> = {
|
|||
str,
|
||||
|
||||
// shrimp runtime info
|
||||
$: {
|
||||
args: Bun.argv.slice(3),
|
||||
argv: Bun.argv.slice(1),
|
||||
env: process.env,
|
||||
pid: process.pid,
|
||||
cwd: process.env.PWD,
|
||||
script: {
|
||||
name: Bun.argv[2] || '(shrimp)',
|
||||
path: resolve(join('.', Bun.argv[2] ?? '')),
|
||||
},
|
||||
},
|
||||
$: runningInBrowser
|
||||
? {
|
||||
args: [],
|
||||
argv: [],
|
||||
env: {},
|
||||
pid: 0,
|
||||
cwd: '',
|
||||
script: {
|
||||
name: '',
|
||||
path: '.',
|
||||
},
|
||||
} : {
|
||||
args: Bun.argv.slice(3),
|
||||
argv: Bun.argv.slice(1),
|
||||
env: process.env,
|
||||
pid: process.pid,
|
||||
cwd: process.env.PWD,
|
||||
script: {
|
||||
name: Bun.argv[2] || '(shrimp)',
|
||||
path: resolve(join('.', Bun.argv[2] ?? '')),
|
||||
},
|
||||
}
|
||||
|
||||
// hello
|
||||
echo: (...args: any[]) => {
|
||||
|
|
@ -49,7 +61,7 @@ export const globals: Record<string, any> = {
|
|||
...args.map((a) => {
|
||||
const v = toValue(a)
|
||||
return ['array', 'dict'].includes(v.type) ? formatValue(v, true) : v.value
|
||||
}),
|
||||
})
|
||||
)
|
||||
return toValue(null)
|
||||
},
|
||||
|
|
@ -120,7 +132,7 @@ export const globals: Record<string, any> = {
|
|||
const idx = typeof index === 'number' ? index : parseInt(index as string)
|
||||
if (idx < 0 || idx >= value.value.length) {
|
||||
throw new Error(
|
||||
`at: index ${idx} out of bounds for ${value.type} of length ${value.value.length}`,
|
||||
`at: index ${idx} out of bounds for ${value.type} of length ${value.value.length}`
|
||||
)
|
||||
}
|
||||
return value.value[idx]
|
||||
|
|
|
|||
|
|
@ -1,10 +0,0 @@
|
|||
import { Editor } from '#/editor/editor'
|
||||
import { render } from 'hono/jsx/dom'
|
||||
import './index.css'
|
||||
|
||||
const App = () => {
|
||||
return <Editor />
|
||||
}
|
||||
|
||||
const root = document.getElementById('root')!
|
||||
render(<App />, root)
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
:root {
|
||||
/* Background colors */
|
||||
--bg-editor: #011627;
|
||||
--bg-output: #40318d;
|
||||
--bg-status-bar: #1e2a4a;
|
||||
--bg-status-border: #0e1a3a;
|
||||
--bg-selection: #1d3b53;
|
||||
--bg-variable-def: #1e2a4a;
|
||||
|
||||
/* Text colors */
|
||||
--text-editor: #d6deeb;
|
||||
--text-output: #7c70da;
|
||||
--text-status: #b3a9ff55;
|
||||
--caret: #80a4c2;
|
||||
|
||||
/* Syntax highlighting colors */
|
||||
--color-keyword: #c792ea;
|
||||
--color-function: #82aaff;
|
||||
--color-string: #c3e88d;
|
||||
--color-number: #f78c6c;
|
||||
--color-bool: #ff5370;
|
||||
--color-operator: #89ddff;
|
||||
--color-paren: #676e95;
|
||||
--color-function-call: #ff9cac;
|
||||
--color-variable-def: #ffcb6b;
|
||||
--color-error: #ff6e6e;
|
||||
--color-regex: #e1acff;
|
||||
|
||||
/* ANSI terminal colors */
|
||||
--ansi-black: #011627;
|
||||
--ansi-red: #ff5370;
|
||||
--ansi-green: #c3e88d;
|
||||
--ansi-yellow: #ffcb6b;
|
||||
--ansi-blue: #82aaff;
|
||||
--ansi-magenta: #c792ea;
|
||||
--ansi-cyan: #89ddff;
|
||||
--ansi-white: #d6deeb;
|
||||
|
||||
/* ANSI bright colors (slightly more vibrant) */
|
||||
--ansi-bright-black: #676e95;
|
||||
--ansi-bright-red: #ff6e90;
|
||||
--ansi-bright-green: #d4f6a8;
|
||||
--ansi-bright-yellow: #ffe082;
|
||||
--ansi-bright-blue: #a8c7fa;
|
||||
--ansi-bright-magenta: #e1acff;
|
||||
--ansi-bright-cyan: #a8f5ff;
|
||||
--ansi-bright-white: #ffffff;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'C64ProMono';
|
||||
src: url('../../assets/C64_Pro_Mono-STYLE.woff2') format('woff2');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Pixeloid Mono';
|
||||
src: url('../../assets/PixeloidMono.ttf') format('truetype');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg-output);
|
||||
color: var(--text-output);
|
||||
font-family: 'Pixeloid Mono', 'Courier New', monospace;
|
||||
font-size: 18px;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#root {
|
||||
height: 100vh;
|
||||
background: var(--bg-output);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Shrimp</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./app.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import index from './index.html'
|
||||
|
||||
const server = Bun.serve({
|
||||
port: process.env.PORT ? Number(process.env.PORT) : 3001,
|
||||
routes: {
|
||||
'/*': index,
|
||||
|
||||
'/api/hello': {
|
||||
async GET(req) {
|
||||
return Response.json({
|
||||
message: 'Hello, world!',
|
||||
method: 'GET',
|
||||
})
|
||||
},
|
||||
async PUT(req) {
|
||||
return Response.json({
|
||||
message: 'Hello, world!',
|
||||
method: 'PUT',
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
development: process.env.NODE_ENV !== 'production' && {
|
||||
hmr: true,
|
||||
console: true,
|
||||
},
|
||||
})
|
||||
|
||||
console.log(`🚀 Server running at ${server.url}`)
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
/**
|
||||
* How to use a Signal:
|
||||
*
|
||||
* Create a signal with primitives:
|
||||
* const nameSignal = new Signal<string>()
|
||||
* const countSignal = new Signal<number>()
|
||||
*
|
||||
* Create a signal with objects:
|
||||
* const chatSignal = new Signal<{ username: string, message: string }>()
|
||||
*
|
||||
* Create a signal with no data (void):
|
||||
* const clickSignal = new Signal<void>()
|
||||
* const clickSignal2 = new Signal() // Defaults to void
|
||||
*
|
||||
* Connect to the signal:
|
||||
* const disconnect = chatSignal.connect((data) => {
|
||||
* const {username, message} = data;
|
||||
* console.log(`${username} said "${message}"`);
|
||||
* })
|
||||
*
|
||||
* Emit a signal:
|
||||
* nameSignal.emit("Alice")
|
||||
* countSignal.emit(42)
|
||||
* chatSignal.emit({ username: "Chad", message: "Hey everyone, how's it going?" });
|
||||
* clickSignal.emit() // No argument for void signals
|
||||
*
|
||||
* Forward a signal:
|
||||
* const relaySignal = new Signal<{ username: string, message: string }>()
|
||||
* const disconnectRelay = chatSignal.connect(relaySignal)
|
||||
* // Now, when chatSignal emits, relaySignal will also emit the same data
|
||||
*
|
||||
* Disconnect a single listener:
|
||||
* disconnect(); // The disconnect function is returned when you connect to a signal
|
||||
*
|
||||
* Disconnect all listeners:
|
||||
* chatSignal.disconnect()
|
||||
*/
|
||||
|
||||
export class Signal<T = void> {
|
||||
private listeners: Array<(data: T) => void> = []
|
||||
|
||||
connect(listenerOrSignal: Signal<T> | ((data: T) => void)) {
|
||||
let listener: (data: T) => void
|
||||
|
||||
// If it is a signal, forward the data to the signal
|
||||
if (listenerOrSignal instanceof Signal) {
|
||||
listener = (data: T) => listenerOrSignal.emit(data)
|
||||
} else {
|
||||
listener = listenerOrSignal
|
||||
}
|
||||
|
||||
this.listeners.push(listener)
|
||||
|
||||
return () => {
|
||||
this.listeners = this.listeners.filter((l) => l !== listener)
|
||||
}
|
||||
}
|
||||
|
||||
emit(data: T) {
|
||||
for (const listener of this.listeners) {
|
||||
listener(data)
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.listeners = []
|
||||
}
|
||||
}
|
||||
|
|
@ -136,3 +136,12 @@ export const asciiEscapeToHtml = (str: string): HtmlEscapedString => {
|
|||
|
||||
return result as HtmlEscapedString
|
||||
}
|
||||
|
||||
export const isDebug = (): boolean => {
|
||||
if (typeof process !== 'undefined' && process.env) {
|
||||
return !!process.env.DEBUG
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export const runningInBrowser = typeof window !== 'undefined'
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user