mirror of
https://github.com/cursorless-dev/cursorless.git
synced 2024-10-04 04:47:29 +03:00
Cursorless tutorial
This commit is contained in:
parent
a2e4a61858
commit
be21ac43e8
22
.vscode/tasks.json
vendored
22
.vscode/tasks.json
vendored
@ -32,6 +32,16 @@
|
||||
},
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "Build tutorial webview",
|
||||
"type": "npm",
|
||||
"script": "build:dev",
|
||||
"path": "packages/cursorless-vscode-tutorial-webview",
|
||||
"presentation": {
|
||||
"reveal": "silent"
|
||||
},
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "Build test harness",
|
||||
"type": "npm",
|
||||
@ -57,6 +67,7 @@
|
||||
"type": "npm",
|
||||
"script": "populate-dist",
|
||||
"path": "packages/cursorless-vscode",
|
||||
"dependsOn": ["Build tutorial webview"],
|
||||
"presentation": {
|
||||
"reveal": "silent"
|
||||
},
|
||||
@ -103,6 +114,17 @@
|
||||
"dependsOn": ["Watch esbuild", "Watch typescript"],
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "watch tutorial",
|
||||
"type": "npm",
|
||||
"script": "watch:tailwind",
|
||||
"isBackground": true,
|
||||
"presentation": {
|
||||
"reveal": "never"
|
||||
},
|
||||
"path": "packages/cursorless-vscode-tutorial-webview",
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "watch:esbuild",
|
||||
|
@ -11,6 +11,7 @@ from .sections.get_scope_visualizer import get_scope_visualizer
|
||||
from .sections.modifiers import get_modifiers
|
||||
from .sections.scopes import get_scopes
|
||||
from .sections.special_marks import get_special_marks
|
||||
from .sections.tutorial import get_tutorial_entries
|
||||
|
||||
mod = Module()
|
||||
ctx = Context()
|
||||
@ -37,6 +38,7 @@ class Actions:
|
||||
|
||||
def private_cursorless_open_instructions():
|
||||
"""Open web page with cursorless instructions"""
|
||||
actions.user.private_cursorless_notify_docs_opened()
|
||||
webbrowser.open(instructions_url)
|
||||
|
||||
|
||||
@ -150,5 +152,10 @@ def cursorless_cheat_sheet_get_json():
|
||||
"id": "shapes",
|
||||
"items": get_list("hat_shape", "hatShape"),
|
||||
},
|
||||
{
|
||||
"name": "Tutorial",
|
||||
"id": "tutorial",
|
||||
"items": get_tutorial_entries(),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
83
cursorless-talon/src/cheatsheet/sections/tutorial.py
Normal file
83
cursorless-talon/src/cheatsheet/sections/tutorial.py
Normal file
@ -0,0 +1,83 @@
|
||||
def get_tutorial_entries():
|
||||
return [
|
||||
{
|
||||
"id": "start_tutorial",
|
||||
"type": "command",
|
||||
"variations": [
|
||||
{
|
||||
"spokenForm": "cursorless tutorial",
|
||||
"description": "Start the introductory Cursorless tutorial",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "tutorial_next",
|
||||
"type": "command",
|
||||
"variations": [
|
||||
{
|
||||
"spokenForm": "tutorial next",
|
||||
"description": "Advance to next step in tutorial",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "tutorial_previous",
|
||||
"type": "command",
|
||||
"variations": [
|
||||
{
|
||||
"spokenForm": "tutorial previous",
|
||||
"description": "Go back to previous step in tutorial",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "tutorial_restart",
|
||||
"type": "command",
|
||||
"variations": [
|
||||
{
|
||||
"spokenForm": "tutorial restart",
|
||||
"description": "Restart the tutorial",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "tutorial_resume",
|
||||
"type": "command",
|
||||
"variations": [
|
||||
{
|
||||
"spokenForm": "tutorial resume",
|
||||
"description": "Resume the tutorial",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "tutorial_list",
|
||||
"type": "command",
|
||||
"variations": [
|
||||
{
|
||||
"spokenForm": "tutorial list",
|
||||
"description": "List all available tutorials",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "tutorial_close",
|
||||
"type": "command",
|
||||
"variations": [
|
||||
{
|
||||
"spokenForm": "tutorial close",
|
||||
"description": "Close the tutorial",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "tutorial_start_by_number",
|
||||
"type": "command",
|
||||
"variations": [
|
||||
{
|
||||
"spokenForm": "tutorial <number>",
|
||||
"description": "Start a specific tutorial by number",
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
@ -1,4 +1,4 @@
|
||||
from talon import Module, actions
|
||||
from talon import Context, Module, actions
|
||||
|
||||
mod = Module()
|
||||
|
||||
@ -7,6 +7,13 @@ mod.tag(
|
||||
"Application supporting cursorless commands",
|
||||
)
|
||||
|
||||
global_ctx = Context()
|
||||
|
||||
cursorless_ctx = Context()
|
||||
cursorless_ctx.matches = r"""
|
||||
tag: user.cursorless
|
||||
"""
|
||||
|
||||
|
||||
@mod.action_class
|
||||
class Actions:
|
||||
@ -16,8 +23,67 @@ class Actions:
|
||||
def private_cursorless_show_sidebar():
|
||||
"""Show Cursorless-specific settings in ide"""
|
||||
|
||||
def private_cursorless_notify_docs_opened():
|
||||
"""Notify the ide that the docs were opened in case the tutorial is waiting for that event"""
|
||||
...
|
||||
|
||||
def private_cursorless_show_command_statistics():
|
||||
"""Show Cursorless command statistics"""
|
||||
actions.user.private_cursorless_run_rpc_command_no_wait(
|
||||
"cursorless.analyzeCommandHistory"
|
||||
)
|
||||
|
||||
def private_cursorless_start_tutorial():
|
||||
"""Start the introductory Cursorless tutorial"""
|
||||
actions.user.private_cursorless_run_rpc_command_no_wait(
|
||||
"cursorless.tutorial.start", "unit-1-basics"
|
||||
)
|
||||
|
||||
def private_cursorless_tutorial_next():
|
||||
"""Cursorless tutorial: next"""
|
||||
actions.user.private_cursorless_run_rpc_command_no_wait(
|
||||
"cursorless.tutorial.next"
|
||||
)
|
||||
|
||||
def private_cursorless_tutorial_previous():
|
||||
"""Cursorless tutorial: previous"""
|
||||
actions.user.private_cursorless_run_rpc_command_no_wait(
|
||||
"cursorless.tutorial.previous"
|
||||
)
|
||||
|
||||
def private_cursorless_tutorial_restart():
|
||||
"""Cursorless tutorial: restart"""
|
||||
actions.user.private_cursorless_run_rpc_command_no_wait(
|
||||
"cursorless.tutorial.restart"
|
||||
)
|
||||
|
||||
def private_cursorless_tutorial_resume():
|
||||
"""Cursorless tutorial: resume"""
|
||||
actions.user.private_cursorless_run_rpc_command_no_wait(
|
||||
"cursorless.tutorial.resume"
|
||||
)
|
||||
|
||||
def private_cursorless_tutorial_list():
|
||||
"""Cursorless tutorial: list all available tutorials"""
|
||||
actions.user.private_cursorless_run_rpc_command_no_wait(
|
||||
"cursorless.tutorial.list"
|
||||
)
|
||||
|
||||
def private_cursorless_tutorial_start_by_number(number: int): # pyright: ignore [reportGeneralTypeIssues]
|
||||
"""Start Cursorless tutorial by number"""
|
||||
actions.user.private_cursorless_run_rpc_command_no_wait(
|
||||
"cursorless.tutorial.start", number - 1
|
||||
)
|
||||
|
||||
|
||||
@global_ctx.action_class("user")
|
||||
class GlobalActions:
|
||||
def private_cursorless_notify_docs_opened():
|
||||
# Do nothing if we're not in a Cursorless context
|
||||
pass
|
||||
|
||||
|
||||
@cursorless_ctx.action_class("user")
|
||||
class CursorlessActions:
|
||||
def private_cursorless_notify_docs_opened():
|
||||
actions.user.private_cursorless_run_rpc_command_no_wait("cursorless.docsOpened")
|
||||
|
@ -37,3 +37,13 @@ bar {user.cursorless_homophone}:
|
||||
|
||||
{user.cursorless_homophone} stats:
|
||||
user.private_cursorless_show_command_statistics()
|
||||
|
||||
{user.cursorless_homophone} tutorial:
|
||||
user.private_cursorless_start_tutorial()
|
||||
tutorial next: user.private_cursorless_tutorial_next()
|
||||
tutorial (previous | last): user.private_cursorless_tutorial_previous()
|
||||
tutorial restart: user.private_cursorless_tutorial_restart()
|
||||
tutorial resume: user.private_cursorless_tutorial_resume()
|
||||
tutorial (list | close): user.private_cursorless_tutorial_list()
|
||||
tutorial <user.private_cursorless_number_small>:
|
||||
user.private_cursorless_tutorial_start_by_number(private_cursorless_number_small)
|
||||
|
30
data/fixtures/recorded/tutorial/unit-1-basics/changeSit.yml
Normal file
30
data/fixtures/recorded/tutorial/unit-1-basics/changeSit.yml
Normal file
@ -0,0 +1,30 @@
|
||||
languageId: plaintext
|
||||
command:
|
||||
version: 7
|
||||
spokenForm: change sit
|
||||
action:
|
||||
name: clearAndSetSelection
|
||||
target:
|
||||
type: primitive
|
||||
mark: {type: decoratedSymbol, symbolColor: default, character: i}
|
||||
usePrePhraseSnapshot: true
|
||||
initialState:
|
||||
documentContents: |
|
||||
Welcome Cursorless!
|
||||
|
||||
Notice the hats above each word in this sentence.
|
||||
selections:
|
||||
- anchor: {line: 2, character: 15}
|
||||
active: {line: 2, character: 15}
|
||||
marks:
|
||||
default.i:
|
||||
start: {line: 2, character: 32}
|
||||
end: {line: 2, character: 34}
|
||||
finalState:
|
||||
documentContents: |
|
||||
Welcome Cursorless!
|
||||
|
||||
Notice the hats above each word this sentence.
|
||||
selections:
|
||||
- anchor: {line: 2, character: 32}
|
||||
active: {line: 2, character: 32}
|
@ -0,0 +1,39 @@
|
||||
languageId: plaintext
|
||||
command:
|
||||
version: 7
|
||||
spokenForm: chuck line odd
|
||||
action:
|
||||
name: remove
|
||||
target:
|
||||
type: primitive
|
||||
mark: {type: decoratedSymbol, symbolColor: default, character: o}
|
||||
modifiers:
|
||||
- type: containingScope
|
||||
scopeType: {type: line}
|
||||
usePrePhraseSnapshot: true
|
||||
initialState:
|
||||
documentContents: |-
|
||||
Welcome Cursorless!
|
||||
|
||||
Notice the hats above each word in this sentence.
|
||||
|
||||
Now, see the sidebar.
|
||||
selections:
|
||||
- anchor: {line: 2, character: 0}
|
||||
active: {line: 2, character: 6}
|
||||
- anchor: {line: 2, character: 35}
|
||||
active: {line: 2, character: 39}
|
||||
marks:
|
||||
default.o:
|
||||
start: {line: 4, character: 0}
|
||||
end: {line: 4, character: 3}
|
||||
finalState:
|
||||
documentContents: |
|
||||
Welcome Cursorless!
|
||||
|
||||
Notice the hats above each word in this sentence.
|
||||
selections:
|
||||
- anchor: {line: 2, character: 0}
|
||||
active: {line: 2, character: 6}
|
||||
- anchor: {line: 2, character: 35}
|
||||
active: {line: 2, character: 39}
|
38
data/fixtures/recorded/tutorial/unit-1-basics/chuckTrap.yml
Normal file
38
data/fixtures/recorded/tutorial/unit-1-basics/chuckTrap.yml
Normal file
@ -0,0 +1,38 @@
|
||||
languageId: plaintext
|
||||
command:
|
||||
version: 7
|
||||
spokenForm: chuck trap
|
||||
action:
|
||||
name: remove
|
||||
target:
|
||||
type: primitive
|
||||
mark: {type: decoratedSymbol, symbolColor: default, character: t}
|
||||
usePrePhraseSnapshot: true
|
||||
initialState:
|
||||
documentContents: |-
|
||||
Welcome to Cursorless!
|
||||
|
||||
Notice the hats above each word in this sentence.
|
||||
|
||||
Now, see the sidebar.
|
||||
selections:
|
||||
- anchor: {line: 2, character: 0}
|
||||
active: {line: 2, character: 6}
|
||||
- anchor: {line: 2, character: 35}
|
||||
active: {line: 2, character: 39}
|
||||
marks:
|
||||
default.t:
|
||||
start: {line: 0, character: 8}
|
||||
end: {line: 0, character: 10}
|
||||
finalState:
|
||||
documentContents: |-
|
||||
Welcome Cursorless!
|
||||
|
||||
Notice the hats above each word in this sentence.
|
||||
|
||||
Now, see the sidebar.
|
||||
selections:
|
||||
- anchor: {line: 2, character: 0}
|
||||
active: {line: 2, character: 6}
|
||||
- anchor: {line: 2, character: 35}
|
||||
active: {line: 2, character: 39}
|
30
data/fixtures/recorded/tutorial/unit-1-basics/postAir.yml
Normal file
30
data/fixtures/recorded/tutorial/unit-1-basics/postAir.yml
Normal file
@ -0,0 +1,30 @@
|
||||
languageId: plaintext
|
||||
command:
|
||||
version: 7
|
||||
spokenForm: post air
|
||||
action:
|
||||
name: setSelectionAfter
|
||||
target:
|
||||
type: primitive
|
||||
mark: {type: decoratedSymbol, symbolColor: default, character: a}
|
||||
usePrePhraseSnapshot: true
|
||||
initialState:
|
||||
documentContents: |
|
||||
Welcome Cursorless!
|
||||
|
||||
Notice the hats above each word in this sentence.
|
||||
selections:
|
||||
- anchor: {line: 0, character: 8}
|
||||
active: {line: 0, character: 8}
|
||||
marks:
|
||||
default.a:
|
||||
start: {line: 2, character: 11}
|
||||
end: {line: 2, character: 15}
|
||||
finalState:
|
||||
documentContents: |
|
||||
Welcome Cursorless!
|
||||
|
||||
Notice the hats above each word in this sentence.
|
||||
selections:
|
||||
- anchor: {line: 2, character: 15}
|
||||
active: {line: 2, character: 15}
|
30
data/fixtures/recorded/tutorial/unit-1-basics/preUrge.yml
Normal file
30
data/fixtures/recorded/tutorial/unit-1-basics/preUrge.yml
Normal file
@ -0,0 +1,30 @@
|
||||
languageId: plaintext
|
||||
command:
|
||||
version: 7
|
||||
spokenForm: pre urge
|
||||
action:
|
||||
name: setSelectionBefore
|
||||
target:
|
||||
type: primitive
|
||||
mark: {type: decoratedSymbol, symbolColor: default, character: u}
|
||||
usePrePhraseSnapshot: true
|
||||
initialState:
|
||||
documentContents: |
|
||||
Welcome Cursorless!
|
||||
|
||||
Notice the hats above each word in this sentence.
|
||||
selections:
|
||||
- anchor: {line: 2, character: 0}
|
||||
active: {line: 2, character: 49}
|
||||
marks:
|
||||
default.u:
|
||||
start: {line: 0, character: 8}
|
||||
end: {line: 0, character: 18}
|
||||
finalState:
|
||||
documentContents: |
|
||||
Welcome Cursorless!
|
||||
|
||||
Notice the hats above each word in this sentence.
|
||||
selections:
|
||||
- anchor: {line: 0, character: 8}
|
||||
active: {line: 0, character: 8}
|
17
data/fixtures/recorded/tutorial/unit-1-basics/script.json
Normal file
17
data/fixtures/recorded/tutorial/unit-1-basics/script.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"title": "Introduction",
|
||||
"version": 0,
|
||||
"steps": [
|
||||
"Say {command:takeCap.yml}",
|
||||
"Well done! 🙌 You just used the code word for 'c', {grapheme:c}, to refer to the word with a gray hat over the 'c'.\nWhen a hat is not gray, we say its color: say {command:takeBlueSun.yml}",
|
||||
"Selecting a single token is great, but oftentimes we need something bigger.\nSay {command:takeHarpPastDrum.yml} to select a range.",
|
||||
"Despite its name, one of the most powerful aspects of cursorless is the ability to use more than one cursor.\nLet's try that: {command:takeNearAndSun.yml}",
|
||||
"But let's show that cursorless can live up to its name: we can say {command:chuckTrap.yml} to delete a word without ever moving our cursor.",
|
||||
"Tokens are great, but they're just one way to think of a document.\nLet's try working with lines: {command:chuckLineOdd.yml}",
|
||||
"We can also use {scopeType:line} to refer to the line containing our cursor: {command:takeLine.yml}",
|
||||
"You now know how to select and delete; let's give you a couple more actions to play with: say {action:pre} to place the cursor before a target, as in {command:preUrge.yml}",
|
||||
"Say {action:post} to place the cursor after a target: {command:postAir.yml}",
|
||||
"Say {action:change} to delete a word and move your cursor to where it used to be: {command:changeSit.yml}",
|
||||
"And that wraps up unit 1 of the cursorless tutorial! Next time, we'll write some code 🙌.\nFeel free to keep playing with this document, then say {special:next} to continue."
|
||||
]
|
||||
}
|
@ -0,0 +1,34 @@
|
||||
languageId: plaintext
|
||||
command:
|
||||
version: 7
|
||||
spokenForm: take blue sun
|
||||
action:
|
||||
name: setSelection
|
||||
target:
|
||||
type: primitive
|
||||
mark: {type: decoratedSymbol, symbolColor: blue, character: s}
|
||||
usePrePhraseSnapshot: true
|
||||
initialState:
|
||||
documentContents: |-
|
||||
Welcome to Cursorless!
|
||||
|
||||
Notice the hats above each word in this sentence.
|
||||
|
||||
Now, see the sidebar.
|
||||
selections:
|
||||
- anchor: {line: 2, character: 22}
|
||||
active: {line: 2, character: 26}
|
||||
marks:
|
||||
blue.s:
|
||||
start: {line: 4, character: 5}
|
||||
end: {line: 4, character: 8}
|
||||
finalState:
|
||||
documentContents: |-
|
||||
Welcome to Cursorless!
|
||||
|
||||
Notice the hats above each word in this sentence.
|
||||
|
||||
Now, see the sidebar.
|
||||
selections:
|
||||
- anchor: {line: 4, character: 5}
|
||||
active: {line: 4, character: 8}
|
34
data/fixtures/recorded/tutorial/unit-1-basics/takeCap.yml
Normal file
34
data/fixtures/recorded/tutorial/unit-1-basics/takeCap.yml
Normal file
@ -0,0 +1,34 @@
|
||||
languageId: plaintext
|
||||
command:
|
||||
version: 7
|
||||
spokenForm: take cap
|
||||
action:
|
||||
name: setSelection
|
||||
target:
|
||||
type: primitive
|
||||
mark: {type: decoratedSymbol, symbolColor: default, character: c}
|
||||
usePrePhraseSnapshot: true
|
||||
initialState:
|
||||
documentContents: |-
|
||||
Welcome to Cursorless!
|
||||
|
||||
Notice the hats above each word in this sentence.
|
||||
|
||||
Now, see the sidebar.
|
||||
selections:
|
||||
- anchor: {line: 0, character: 0}
|
||||
active: {line: 0, character: 0}
|
||||
marks:
|
||||
default.c:
|
||||
start: {line: 2, character: 22}
|
||||
end: {line: 2, character: 26}
|
||||
finalState:
|
||||
documentContents: |-
|
||||
Welcome to Cursorless!
|
||||
|
||||
Notice the hats above each word in this sentence.
|
||||
|
||||
Now, see the sidebar.
|
||||
selections:
|
||||
- anchor: {line: 2, character: 22}
|
||||
active: {line: 2, character: 26}
|
@ -0,0 +1,44 @@
|
||||
languageId: plaintext
|
||||
command:
|
||||
version: 7
|
||||
spokenForm: take harp past drum
|
||||
action:
|
||||
name: setSelection
|
||||
target:
|
||||
type: range
|
||||
anchor:
|
||||
type: primitive
|
||||
mark: {type: decoratedSymbol, symbolColor: default, character: h}
|
||||
active:
|
||||
type: primitive
|
||||
mark: {type: decoratedSymbol, symbolColor: default, character: d}
|
||||
excludeAnchor: false
|
||||
excludeActive: false
|
||||
usePrePhraseSnapshot: true
|
||||
initialState:
|
||||
documentContents: |-
|
||||
Welcome to Cursorless!
|
||||
|
||||
Notice the hats above each word in this sentence.
|
||||
|
||||
Now, see the sidebar.
|
||||
selections:
|
||||
- anchor: {line: 4, character: 5}
|
||||
active: {line: 4, character: 8}
|
||||
marks:
|
||||
default.h:
|
||||
start: {line: 2, character: 7}
|
||||
end: {line: 2, character: 10}
|
||||
default.d:
|
||||
start: {line: 2, character: 27}
|
||||
end: {line: 2, character: 31}
|
||||
finalState:
|
||||
documentContents: |-
|
||||
Welcome to Cursorless!
|
||||
|
||||
Notice the hats above each word in this sentence.
|
||||
|
||||
Now, see the sidebar.
|
||||
selections:
|
||||
- anchor: {line: 2, character: 7}
|
||||
active: {line: 2, character: 31}
|
31
data/fixtures/recorded/tutorial/unit-1-basics/takeLine.yml
Normal file
31
data/fixtures/recorded/tutorial/unit-1-basics/takeLine.yml
Normal file
@ -0,0 +1,31 @@
|
||||
languageId: plaintext
|
||||
command:
|
||||
version: 7
|
||||
spokenForm: take line
|
||||
action:
|
||||
name: setSelection
|
||||
target:
|
||||
type: primitive
|
||||
modifiers:
|
||||
- type: containingScope
|
||||
scopeType: {type: line}
|
||||
usePrePhraseSnapshot: true
|
||||
initialState:
|
||||
documentContents: |
|
||||
Welcome Cursorless!
|
||||
|
||||
Notice the hats above each word in this sentence.
|
||||
selections:
|
||||
- anchor: {line: 2, character: 0}
|
||||
active: {line: 2, character: 6}
|
||||
- anchor: {line: 2, character: 35}
|
||||
active: {line: 2, character: 39}
|
||||
marks: {}
|
||||
finalState:
|
||||
documentContents: |
|
||||
Welcome Cursorless!
|
||||
|
||||
Notice the hats above each word in this sentence.
|
||||
selections:
|
||||
- anchor: {line: 2, character: 0}
|
||||
active: {line: 2, character: 49}
|
@ -0,0 +1,43 @@
|
||||
languageId: plaintext
|
||||
command:
|
||||
version: 7
|
||||
spokenForm: take near and sun
|
||||
action:
|
||||
name: setSelection
|
||||
target:
|
||||
type: list
|
||||
elements:
|
||||
- type: primitive
|
||||
mark: {type: decoratedSymbol, symbolColor: default, character: 'n'}
|
||||
- type: primitive
|
||||
mark: {type: decoratedSymbol, symbolColor: default, character: s}
|
||||
usePrePhraseSnapshot: true
|
||||
initialState:
|
||||
documentContents: |-
|
||||
Welcome to Cursorless!
|
||||
|
||||
Notice the hats above each word in this sentence.
|
||||
|
||||
Now, see the sidebar.
|
||||
selections:
|
||||
- anchor: {line: 2, character: 7}
|
||||
active: {line: 2, character: 31}
|
||||
marks:
|
||||
default.n:
|
||||
start: {line: 2, character: 0}
|
||||
end: {line: 2, character: 6}
|
||||
default.s:
|
||||
start: {line: 2, character: 35}
|
||||
end: {line: 2, character: 39}
|
||||
finalState:
|
||||
documentContents: |-
|
||||
Welcome to Cursorless!
|
||||
|
||||
Notice the hats above each word in this sentence.
|
||||
|
||||
Now, see the sidebar.
|
||||
selections:
|
||||
- anchor: {line: 2, character: 0}
|
||||
active: {line: 2, character: 6}
|
||||
- anchor: {line: 2, character: 35}
|
||||
active: {line: 2, character: 39}
|
@ -0,0 +1,68 @@
|
||||
languageId: python
|
||||
command:
|
||||
version: 6
|
||||
spokenForm: bring blue cap to value red
|
||||
action:
|
||||
name: replaceWithTarget
|
||||
source:
|
||||
type: primitive
|
||||
mark: {type: decoratedSymbol, symbolColor: blue, character: c}
|
||||
destination:
|
||||
type: primitive
|
||||
insertionMode: to
|
||||
target:
|
||||
type: primitive
|
||||
modifiers:
|
||||
- type: containingScope
|
||||
scopeType: {type: value}
|
||||
mark: {type: decoratedSymbol, symbolColor: default, character: r}
|
||||
usePrePhraseSnapshot: false
|
||||
initialState:
|
||||
documentContents: |
|
||||
def print_color(color, invert=False):
|
||||
if invert:
|
||||
print(invert_color(color))
|
||||
else:
|
||||
print(color)
|
||||
|
||||
|
||||
def invert_color(color):
|
||||
if color == "black":
|
||||
return "white"
|
||||
if color == "white":
|
||||
return "black"
|
||||
return "black"
|
||||
|
||||
|
||||
print_color("black")
|
||||
selections:
|
||||
- anchor: {line: 12, character: 18}
|
||||
active: {line: 12, character: 18}
|
||||
marks:
|
||||
blue.c:
|
||||
start: {line: 7, character: 17}
|
||||
end: {line: 7, character: 22}
|
||||
default.r:
|
||||
start: {line: 12, character: 4}
|
||||
end: {line: 12, character: 10}
|
||||
finalState:
|
||||
documentContents: |
|
||||
def print_color(color, invert=False):
|
||||
if invert:
|
||||
print(invert_color(color))
|
||||
else:
|
||||
print(color)
|
||||
|
||||
|
||||
def invert_color(color):
|
||||
if color == "black":
|
||||
return "white"
|
||||
if color == "white":
|
||||
return "black"
|
||||
return color
|
||||
|
||||
|
||||
print_color("black")
|
||||
selections:
|
||||
- anchor: {line: 12, character: 16}
|
||||
active: {line: 12, character: 16}
|
@ -0,0 +1,60 @@
|
||||
languageId: python
|
||||
command:
|
||||
version: 6
|
||||
spokenForm: bring state urge
|
||||
action:
|
||||
name: replaceWithTarget
|
||||
source:
|
||||
type: primitive
|
||||
modifiers:
|
||||
- type: containingScope
|
||||
scopeType: {type: statement}
|
||||
mark: {type: decoratedSymbol, symbolColor: default, character: u}
|
||||
destination: {type: implicit}
|
||||
usePrePhraseSnapshot: false
|
||||
initialState:
|
||||
documentContents: |
|
||||
def print_color(color, invert=False):
|
||||
if invert:
|
||||
print(invert_color(color))
|
||||
else:
|
||||
print(color)
|
||||
|
||||
|
||||
def invert_color(color):
|
||||
if color == "black":
|
||||
return "white"
|
||||
if color == "white":
|
||||
return "black"
|
||||
|
||||
|
||||
|
||||
print_color("black")
|
||||
selections:
|
||||
- anchor: {line: 12, character: 4}
|
||||
active: {line: 12, character: 4}
|
||||
marks:
|
||||
default.u:
|
||||
start: {line: 11, character: 8}
|
||||
end: {line: 11, character: 14}
|
||||
finalState:
|
||||
documentContents: |
|
||||
def print_color(color, invert=False):
|
||||
if invert:
|
||||
print(invert_color(color))
|
||||
else:
|
||||
print(color)
|
||||
|
||||
|
||||
def invert_color(color):
|
||||
if color == "black":
|
||||
return "white"
|
||||
if color == "white":
|
||||
return "black"
|
||||
return "black"
|
||||
|
||||
|
||||
print_color("black")
|
||||
selections:
|
||||
- anchor: {line: 12, character: 18}
|
||||
active: {line: 12, character: 18}
|
@ -0,0 +1,59 @@
|
||||
languageId: python
|
||||
command:
|
||||
version: 6
|
||||
spokenForm: chuck arg blue vest
|
||||
action:
|
||||
name: remove
|
||||
target:
|
||||
type: primitive
|
||||
modifiers:
|
||||
- type: containingScope
|
||||
scopeType: {type: argumentOrParameter}
|
||||
mark: {type: decoratedSymbol, symbolColor: blue, character: v}
|
||||
usePrePhraseSnapshot: false
|
||||
initialState:
|
||||
documentContents: |
|
||||
def print_color(color, invert=False):
|
||||
if invert:
|
||||
print(invert_color(color))
|
||||
else:
|
||||
print(color)
|
||||
|
||||
|
||||
def invert_color(color):
|
||||
if color == "black":
|
||||
return "white"
|
||||
if color == "white":
|
||||
return "black"
|
||||
return color
|
||||
|
||||
|
||||
print_color("black")
|
||||
selections:
|
||||
- anchor: {line: 12, character: 16}
|
||||
active: {line: 12, character: 16}
|
||||
marks:
|
||||
blue.v:
|
||||
start: {line: 0, character: 23}
|
||||
end: {line: 0, character: 29}
|
||||
finalState:
|
||||
documentContents: |
|
||||
def print_color(color):
|
||||
if invert:
|
||||
print(invert_color(color))
|
||||
else:
|
||||
print(color)
|
||||
|
||||
|
||||
def invert_color(color):
|
||||
if color == "black":
|
||||
return "white"
|
||||
if color == "white":
|
||||
return "black"
|
||||
return color
|
||||
|
||||
|
||||
print_color("black")
|
||||
selections:
|
||||
- anchor: {line: 12, character: 16}
|
||||
active: {line: 12, character: 16}
|
@ -0,0 +1,55 @@
|
||||
languageId: python
|
||||
command:
|
||||
version: 6
|
||||
spokenForm: clone state sit
|
||||
action:
|
||||
name: insertCopyAfter
|
||||
target:
|
||||
type: primitive
|
||||
modifiers:
|
||||
- type: containingScope
|
||||
scopeType: {type: statement}
|
||||
mark: {type: decoratedSymbol, symbolColor: default, character: i}
|
||||
usePrePhraseSnapshot: false
|
||||
initialState:
|
||||
documentContents: |
|
||||
def print_color(color, invert=False):
|
||||
if invert:
|
||||
print(invert_color(color))
|
||||
else:
|
||||
print(color)
|
||||
|
||||
|
||||
def invert_color(color):
|
||||
if color == "black":
|
||||
return "white"
|
||||
|
||||
|
||||
print_color("black")
|
||||
selections:
|
||||
- anchor: {line: 13, character: 0}
|
||||
active: {line: 13, character: 0}
|
||||
marks:
|
||||
default.i:
|
||||
start: {line: 8, character: 4}
|
||||
end: {line: 8, character: 6}
|
||||
finalState:
|
||||
documentContents: |
|
||||
def print_color(color, invert=False):
|
||||
if invert:
|
||||
print(invert_color(color))
|
||||
else:
|
||||
print(color)
|
||||
|
||||
|
||||
def invert_color(color):
|
||||
if color == "black":
|
||||
return "white"
|
||||
if color == "black":
|
||||
return "white"
|
||||
|
||||
|
||||
print_color("black")
|
||||
selections:
|
||||
- anchor: {line: 15, character: 0}
|
||||
active: {line: 15, character: 0}
|
@ -0,0 +1,53 @@
|
||||
languageId: python
|
||||
command:
|
||||
version: 6
|
||||
spokenForm: dedent this
|
||||
action:
|
||||
name: outdentLine
|
||||
target:
|
||||
type: primitive
|
||||
mark: {type: cursor}
|
||||
usePrePhraseSnapshot: false
|
||||
initialState:
|
||||
documentContents: |
|
||||
def print_color(color, invert=False):
|
||||
if invert:
|
||||
print(invert_color(color))
|
||||
else:
|
||||
print(color)
|
||||
|
||||
|
||||
def invert_color(color):
|
||||
if color == "black":
|
||||
return "white"
|
||||
if color == "white":
|
||||
return "black"
|
||||
|
||||
|
||||
|
||||
print_color("black")
|
||||
selections:
|
||||
- anchor: {line: 12, character: 8}
|
||||
active: {line: 12, character: 8}
|
||||
marks: {}
|
||||
finalState:
|
||||
documentContents: |
|
||||
def print_color(color, invert=False):
|
||||
if invert:
|
||||
print(invert_color(color))
|
||||
else:
|
||||
print(color)
|
||||
|
||||
|
||||
def invert_color(color):
|
||||
if color == "black":
|
||||
return "white"
|
||||
if color == "white":
|
||||
return "black"
|
||||
|
||||
|
||||
|
||||
print_color("black")
|
||||
selections:
|
||||
- anchor: {line: 12, character: 4}
|
||||
active: {line: 12, character: 4}
|
@ -0,0 +1,55 @@
|
||||
languageId: python
|
||||
command:
|
||||
version: 6
|
||||
spokenForm: pour urge
|
||||
action:
|
||||
name: editNewLineAfter
|
||||
target:
|
||||
type: primitive
|
||||
mark: {type: decoratedSymbol, symbolColor: default, character: u}
|
||||
usePrePhraseSnapshot: false
|
||||
initialState:
|
||||
documentContents: |
|
||||
def print_color(color, invert=False):
|
||||
if invert:
|
||||
print(invert_color(color))
|
||||
else:
|
||||
print(color)
|
||||
|
||||
|
||||
def invert_color(color):
|
||||
if color == "black":
|
||||
return "white"
|
||||
if color == "white":
|
||||
return "black"
|
||||
|
||||
|
||||
print_color("black")
|
||||
selections:
|
||||
- anchor: {line: 15, character: 0}
|
||||
active: {line: 15, character: 0}
|
||||
marks:
|
||||
default.u:
|
||||
start: {line: 11, character: 8}
|
||||
end: {line: 11, character: 14}
|
||||
finalState:
|
||||
documentContents: |
|
||||
def print_color(color, invert=False):
|
||||
if invert:
|
||||
print(invert_color(color))
|
||||
else:
|
||||
print(color)
|
||||
|
||||
|
||||
def invert_color(color):
|
||||
if color == "black":
|
||||
return "white"
|
||||
if color == "white":
|
||||
return "black"
|
||||
|
||||
|
||||
|
||||
print_color("black")
|
||||
selections:
|
||||
- anchor: {line: 12, character: 8}
|
||||
active: {line: 12, character: 8}
|
@ -0,0 +1,17 @@
|
||||
{
|
||||
"title": "Basic coding",
|
||||
"version": 0,
|
||||
"steps": [
|
||||
"When editing code, we often think in terms of statements, functions, etc. Let's clone a statement: {command:cloneStateInk.yml}",
|
||||
"{scopeType:state} is one of many scopes supported by cursorless. To see all available scopes, have a look at the Scopes section below, and use the {term:visualize} command to see them live: {visualize:funk}",
|
||||
"Say {special:visualizeNothing} to hide the visualization.",
|
||||
"Cursorless tries its best to keep your commands short.\nIn the following command, we just say {scopeType:string} once, but cursorless infers that both targets are strings: {command:swapStringAirWithWhale.yml}",
|
||||
"Great. Let's learn a new action. The {action:pour} action lets you start editing a new line below any line on your screen: {command:pourUrge.yml}",
|
||||
"Now let's try applying a cursorless action to the current line: {command:dedentThis.yml}",
|
||||
"Code reuse is a fact of life as a programmer. Cursorless makes this easy with the {action:bring} command: {command:bringStateUrge.yml}",
|
||||
"{action:bring} also works with two targets just like {action:swap}: {command:bringBlueCapToValueRisk.yml}",
|
||||
"Cursorless tries its best to use its knowledge of programming languages to leave you with syntactically valid code.\nNote how it cleans up the comma here: {command:chuckArgueBlueVest.yml}",
|
||||
"We introduced a lot of different scopes today. If you're anything like us, you've already forgotten them all.\nThe important thing to remember is that you can always say {special:help} to see a list.",
|
||||
"As always, feel free to stick around and play with this file to practice what you've just learned. Happy coding 😊. Say {special:next} to get back home."
|
||||
]
|
||||
}
|
@ -0,0 +1,63 @@
|
||||
languageId: python
|
||||
command:
|
||||
version: 6
|
||||
spokenForm: swap string air with whale
|
||||
action:
|
||||
name: swapTargets
|
||||
target1:
|
||||
type: primitive
|
||||
modifiers:
|
||||
- type: containingScope
|
||||
scopeType: {type: surroundingPair, delimiter: string}
|
||||
mark: {type: decoratedSymbol, symbolColor: default, character: a}
|
||||
target2:
|
||||
type: primitive
|
||||
mark: {type: decoratedSymbol, symbolColor: default, character: w}
|
||||
usePrePhraseSnapshot: false
|
||||
initialState:
|
||||
documentContents: |
|
||||
def print_color(color, invert=False):
|
||||
if invert:
|
||||
print(invert_color(color))
|
||||
else:
|
||||
print(color)
|
||||
|
||||
|
||||
def invert_color(color):
|
||||
if color == "black":
|
||||
return "white"
|
||||
if color == "black":
|
||||
return "white"
|
||||
|
||||
|
||||
print_color("black")
|
||||
selections:
|
||||
- anchor: {line: 15, character: 0}
|
||||
active: {line: 15, character: 0}
|
||||
marks:
|
||||
default.a:
|
||||
start: {line: 10, character: 17}
|
||||
end: {line: 10, character: 22}
|
||||
default.w:
|
||||
start: {line: 11, character: 16}
|
||||
end: {line: 11, character: 21}
|
||||
finalState:
|
||||
documentContents: |
|
||||
def print_color(color, invert=False):
|
||||
if invert:
|
||||
print(invert_color(color))
|
||||
else:
|
||||
print(color)
|
||||
|
||||
|
||||
def invert_color(color):
|
||||
if color == "black":
|
||||
return "white"
|
||||
if color == "white":
|
||||
return "black"
|
||||
|
||||
|
||||
print_color("black")
|
||||
selections:
|
||||
- anchor: {line: 15, character: 0}
|
||||
active: {line: 15, character: 0}
|
9
data/playground/tutorial/extra-cloning-a-talon-list.py
Normal file
9
data/playground/tutorial/extra-cloning-a-talon-list.py
Normal file
@ -0,0 +1,9 @@
|
||||
from talon import Context, Module
|
||||
|
||||
mod = Module()
|
||||
ctx = Context()
|
||||
|
||||
mod.list("cursorless_walkthrough_list", desc="My tutorial list")
|
||||
ctx.list["user.cursorless_walkthrough_list"] = {
|
||||
"spoken form": "whatever",
|
||||
}
|
11
data/playground/tutorial/unit-1-basics.txt
Normal file
11
data/playground/tutorial/unit-1-basics.txt
Normal file
@ -0,0 +1,11 @@
|
||||
==================================================
|
||||
========== ==========
|
||||
========== Welcome to Cursorless! ==========
|
||||
========== ==========
|
||||
========== Let's start using marks ==========
|
||||
========== ==========
|
||||
========== so we can navigate around ==========
|
||||
========== ==========
|
||||
========== without lifting a finger! ==========
|
||||
========== ==========
|
||||
==================================================
|
13
data/playground/tutorial/unit-2-basic-coding.py
Normal file
13
data/playground/tutorial/unit-2-basic-coding.py
Normal file
@ -0,0 +1,13 @@
|
||||
def print_color(color, invert=False):
|
||||
if invert:
|
||||
print(invert_color(color))
|
||||
else:
|
||||
print(color)
|
||||
|
||||
|
||||
def invert_color(color):
|
||||
if color == "black":
|
||||
return "white"
|
||||
|
||||
|
||||
print_color("black")
|
@ -1788,6 +1788,92 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Tutorial",
|
||||
"id": "tutorial",
|
||||
"items": [
|
||||
{
|
||||
"id": "start_tutorial",
|
||||
"type": "command",
|
||||
"variations": [
|
||||
{
|
||||
"spokenForm": "cursorless tutorial",
|
||||
"description": "Start the introductory Cursorless tutorial"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "tutorial_close",
|
||||
"type": "command",
|
||||
"variations": [
|
||||
{
|
||||
"spokenForm": "tutorial close",
|
||||
"description": "Close the tutorial"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "tutorial_list",
|
||||
"type": "command",
|
||||
"variations": [
|
||||
{
|
||||
"spokenForm": "tutorial list",
|
||||
"description": "List all available tutorials"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "tutorial_next",
|
||||
"type": "command",
|
||||
"variations": [
|
||||
{
|
||||
"spokenForm": "tutorial next",
|
||||
"description": "Advance to next step in tutorial"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "tutorial_previous",
|
||||
"type": "command",
|
||||
"variations": [
|
||||
{
|
||||
"spokenForm": "tutorial previous",
|
||||
"description": "Go back to previous step in tutorial"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "tutorial_restart",
|
||||
"type": "command",
|
||||
"variations": [
|
||||
{
|
||||
"spokenForm": "tutorial restart",
|
||||
"description": "Restart the tutorial"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "tutorial_resume",
|
||||
"type": "command",
|
||||
"variations": [
|
||||
{
|
||||
"spokenForm": "tutorial resume",
|
||||
"description": "Resume the tutorial"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "tutorial_start_by_number",
|
||||
"type": "command",
|
||||
"variations": [
|
||||
{
|
||||
"spokenForm": "tutorial <number>",
|
||||
"description": "Start a specific tutorial by number"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
@ -52,6 +52,13 @@ export const cursorlessCommandIds = [
|
||||
"cursorless.toggleDecorations",
|
||||
"cursorless.showScopeVisualizer",
|
||||
"cursorless.hideScopeVisualizer",
|
||||
"cursorless.tutorial.start",
|
||||
"cursorless.tutorial.next",
|
||||
"cursorless.tutorial.previous",
|
||||
"cursorless.tutorial.restart",
|
||||
"cursorless.tutorial.resume",
|
||||
"cursorless.tutorial.list",
|
||||
"cursorless.docsOpened",
|
||||
"cursorless.analyzeCommandHistory",
|
||||
] as const satisfies readonly `cursorless.${string}`[];
|
||||
|
||||
@ -92,6 +99,15 @@ export const cursorlessCommandDescriptions: Record<
|
||||
"Analyze collected command history",
|
||||
),
|
||||
|
||||
["cursorless.tutorial.start"]: new HiddenCommand("Start a tutorial"),
|
||||
["cursorless.tutorial.next"]: new VisibleCommand("Tutorial next"),
|
||||
["cursorless.tutorial.previous"]: new VisibleCommand("Tutorial previous"),
|
||||
["cursorless.tutorial.restart"]: new VisibleCommand("Tutorial restart"),
|
||||
["cursorless.tutorial.resume"]: new VisibleCommand("Tutorial resume"),
|
||||
["cursorless.tutorial.list"]: new VisibleCommand("Tutorial list"),
|
||||
["cursorless.docsOpened"]: new HiddenCommand(
|
||||
"Used by talon to notify us that the docs have been opened; for use with tutorial",
|
||||
),
|
||||
["cursorless.command"]: new HiddenCommand("The core cursorless command"),
|
||||
["cursorless.repeatPreviousCommand"]: new VisibleCommand(
|
||||
"Repeat the previous Cursorless command",
|
||||
|
@ -1,11 +1,27 @@
|
||||
import { TutorialId } from "../../types/tutorial.types";
|
||||
|
||||
interface SingleTutorialProgress {
|
||||
currentStep: number;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export type TutorialProgress = Partial<
|
||||
Record<TutorialId, SingleTutorialProgress>
|
||||
>;
|
||||
|
||||
export interface StateData {
|
||||
hideInferenceWarning: boolean;
|
||||
tutorialProgress: TutorialProgress;
|
||||
}
|
||||
export type StateKey = keyof StateData;
|
||||
|
||||
/**
|
||||
* A mapping from allowable state keys to their default values
|
||||
*/
|
||||
export const STATE_DEFAULTS = {
|
||||
export const STATE_DEFAULTS: StateData = {
|
||||
hideInferenceWarning: false,
|
||||
tutorialProgress: {},
|
||||
};
|
||||
export type StateData = typeof STATE_DEFAULTS;
|
||||
export type StateKey = keyof StateData;
|
||||
|
||||
/**
|
||||
* A state represents a storage utility. It can store and retrieve
|
||||
|
44
packages/common/src/ide/types/TutorialContentProvider.ts
Normal file
44
packages/common/src/ide/types/TutorialContentProvider.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import { TestCaseFixtureLegacy } from "../../types/TestCaseFixture";
|
||||
import { TutorialId } from "../../types/tutorial.types";
|
||||
|
||||
export interface TutorialContentProvider {
|
||||
/**
|
||||
* Loads the raw content of all tutorials. Just includes the information in
|
||||
* the scripts, not the fixtures that represent commands to run.
|
||||
*/
|
||||
loadRawTutorials(): Promise<RawTutorialContent[]>;
|
||||
|
||||
/**
|
||||
* Loads a fixture file from the tutorial directory, eg "takeNear.yml"
|
||||
*
|
||||
* @param tutorialId The tutorial id
|
||||
* @param fixtureName The name of the fixture, eg "takeNear.yml"
|
||||
* @returns A promise that resolves to the parsed fixture content
|
||||
*/
|
||||
loadFixture(
|
||||
tutorialId: TutorialId,
|
||||
fixtureName: string,
|
||||
): Promise<TestCaseFixtureLegacy>;
|
||||
}
|
||||
|
||||
export interface RawTutorialContent {
|
||||
/**
|
||||
* The unique identifier for the tutorial
|
||||
*/
|
||||
id: TutorialId;
|
||||
|
||||
/**
|
||||
* The title of the tutorial
|
||||
*/
|
||||
title: string;
|
||||
|
||||
/**
|
||||
* The version of the tutorial
|
||||
*/
|
||||
version: number;
|
||||
|
||||
/**
|
||||
* The steps of the current tutorial
|
||||
*/
|
||||
steps: string[];
|
||||
}
|
@ -29,6 +29,7 @@ export * from "./ide/types/events.types";
|
||||
export * from "./ide/types/Paths";
|
||||
export * from "./ide/types/CommandHistoryStorage";
|
||||
export * from "./ide/types/RawTreeSitterQueryProvider";
|
||||
export * from "./ide/types/TutorialContentProvider";
|
||||
export * from "./ide/types/FileSystem.types";
|
||||
export * from "./types/RangeExpansionBehavior";
|
||||
export * from "./types/InputBoxOptions";
|
||||
@ -52,6 +53,7 @@ export * from "./types/commandHistory";
|
||||
export * from "./types/TalonSpokenForms";
|
||||
export * from "./types/TestHelpers";
|
||||
export * from "./types/TreeSitter";
|
||||
export * from "./types/tutorial.types";
|
||||
export * from "./util/textFormatters";
|
||||
export * from "./util/regex";
|
||||
export * from "./util/serializedMarksToTokenHats";
|
||||
|
75
packages/common/src/types/tutorial.types.ts
Normal file
75
packages/common/src/types/tutorial.types.ts
Normal file
@ -0,0 +1,75 @@
|
||||
export type TutorialId = "unit-1-basics" | "unit-2-basic-coding";
|
||||
|
||||
interface BaseTutorialInfo {
|
||||
id: TutorialId;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface TutorialInfo extends BaseTutorialInfo {
|
||||
version: number;
|
||||
stepCount: number;
|
||||
currentStep: number;
|
||||
}
|
||||
|
||||
interface PickingTutorialState {
|
||||
type: "pickingTutorial";
|
||||
tutorials: TutorialInfo[];
|
||||
}
|
||||
|
||||
interface LoadingState {
|
||||
type: "loading";
|
||||
}
|
||||
|
||||
/**
|
||||
* Descriptive text as part of a tutorial step
|
||||
*/
|
||||
interface TutorialStepStringFragment {
|
||||
type: "string";
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A command embedded in a tutorial step that the user must say
|
||||
*/
|
||||
interface TutorialStepCommandFragment {
|
||||
type: "command";
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* A term embedded in a tutorial step. This does not correspond to a complete
|
||||
* command, but rather a single term that can be part of a command. For example:
|
||||
* a scope, action name, etc
|
||||
*/
|
||||
interface TutorialStepTermFragment {
|
||||
type: "term";
|
||||
value: string;
|
||||
}
|
||||
|
||||
export type TutorialStepFragment =
|
||||
| TutorialStepCommandFragment
|
||||
| TutorialStepStringFragment
|
||||
| TutorialStepTermFragment;
|
||||
|
||||
interface ActiveTutorialState extends BaseTutorialInfo {
|
||||
type: "doingTutorial";
|
||||
stepNumber: number;
|
||||
preConditionsMet: boolean;
|
||||
}
|
||||
|
||||
export interface ActiveTutorialNoErrorsState extends ActiveTutorialState {
|
||||
hasErrors: false;
|
||||
stepContent: TutorialStepFragment[][];
|
||||
stepCount: number;
|
||||
}
|
||||
|
||||
export interface ActiveTutorialErrorsState extends ActiveTutorialState {
|
||||
hasErrors: true;
|
||||
requiresTalonUpdate: boolean;
|
||||
}
|
||||
|
||||
export type TutorialState =
|
||||
| PickingTutorialState
|
||||
| LoadingState
|
||||
| ActiveTutorialNoErrorsState
|
||||
| ActiveTutorialErrorsState;
|
@ -8,6 +8,7 @@ import type {
|
||||
} from "@cursorless/common";
|
||||
import type { CommandRunner } from "../CommandRunner";
|
||||
import type { StoredTargetMap } from "../core/StoredTargets";
|
||||
import { Tutorial } from "./Tutorial";
|
||||
|
||||
export interface CursorlessEngine {
|
||||
commandApi: CommandApi;
|
||||
@ -15,6 +16,7 @@ export interface CursorlessEngine {
|
||||
customSpokenFormGenerator: CustomSpokenFormGenerator;
|
||||
storedTargets: StoredTargetMap;
|
||||
hatTokenMap: HatTokenMap;
|
||||
tutorial: Tutorial;
|
||||
injectIde: (ide: IDE | undefined) => void;
|
||||
runIntegrationTests: () => Promise<void>;
|
||||
addCommandRunnerDecorator: (
|
||||
|
30
packages/cursorless-engine/src/api/Tutorial.ts
Normal file
30
packages/cursorless-engine/src/api/Tutorial.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import {
|
||||
Disposable,
|
||||
ScopeType,
|
||||
TutorialId,
|
||||
TutorialState,
|
||||
} from "@cursorless/common";
|
||||
|
||||
export interface Tutorial {
|
||||
start(id: TutorialId | number): Promise<void>;
|
||||
next(): Promise<void>;
|
||||
previous(): Promise<void>;
|
||||
restart(): Promise<void>;
|
||||
resume(): Promise<void>;
|
||||
list(): Promise<void>;
|
||||
|
||||
onState(callback: (state: TutorialState) => void): Disposable;
|
||||
readonly state: TutorialState;
|
||||
|
||||
/**
|
||||
* Call this when the user opens the documentation so that the tutorial can
|
||||
* advance to the next step if it's waiting for that.
|
||||
*/
|
||||
docsOpened(): void;
|
||||
|
||||
/**
|
||||
* Call this when the user visualizes a scope type so that the tutorial can
|
||||
* advance to the next step if it's waiting for that.
|
||||
*/
|
||||
scopeTypeVisualized(scopeType: ScopeType | undefined): void;
|
||||
}
|
@ -4,6 +4,7 @@ import {
|
||||
Hats,
|
||||
IDE,
|
||||
ScopeProvider,
|
||||
TutorialContentProvider,
|
||||
ensureCommandShape,
|
||||
type RawTreeSitterQueryProvider,
|
||||
type TalonSpokenForms,
|
||||
@ -18,6 +19,7 @@ import { Debug } from "./core/Debug";
|
||||
import { HatTokenMapImpl } from "./core/HatTokenMapImpl";
|
||||
import type { Snippets } from "./core/Snippets";
|
||||
import { StoredTargetMap } from "./core/StoredTargets";
|
||||
import { TutorialImpl } from "./tutorial/TutorialImpl";
|
||||
import { RangeUpdater } from "./core/updateSelections/RangeUpdater";
|
||||
import { DisabledCommandServerApi } from "./disabledComponents/DisabledCommandServerApi";
|
||||
import { DisabledHatTokenMap } from "./disabledComponents/DisabledHatTokenMap";
|
||||
@ -40,14 +42,17 @@ import { ScopeRangeWatcher } from "./scopeProviders/ScopeRangeWatcher";
|
||||
import { ScopeSupportChecker } from "./scopeProviders/ScopeSupportChecker";
|
||||
import { ScopeSupportWatcher } from "./scopeProviders/ScopeSupportWatcher";
|
||||
import { injectIde } from "./singletons/ide.singleton";
|
||||
import { DisabledTutorial } from "./disabledComponents/DisabledTutorial";
|
||||
import { Tutorial } from "./api/Tutorial";
|
||||
|
||||
interface Props {
|
||||
export interface EngineProps {
|
||||
ide: IDE;
|
||||
hats?: Hats;
|
||||
treeSitterQueryProvider?: RawTreeSitterQueryProvider;
|
||||
treeSitter?: TreeSitter;
|
||||
commandServerApi?: CommandServerApi;
|
||||
talonSpokenForms?: TalonSpokenForms;
|
||||
tutorialContentProvider?: TutorialContentProvider;
|
||||
snippets?: Snippets;
|
||||
}
|
||||
|
||||
@ -58,8 +63,9 @@ export async function createCursorlessEngine({
|
||||
treeSitter = new DisabledTreeSitter(),
|
||||
commandServerApi = new DisabledCommandServerApi(),
|
||||
talonSpokenForms = new DisabledTalonSpokenForms(),
|
||||
tutorialContentProvider,
|
||||
snippets = new DisabledSnippets(),
|
||||
}: Props): Promise<CursorlessEngine> {
|
||||
}: EngineProps): Promise<CursorlessEngine> {
|
||||
injectIde(ide);
|
||||
|
||||
const debug = new Debug(ide);
|
||||
@ -84,16 +90,34 @@ export async function createCursorlessEngine({
|
||||
)
|
||||
: new DisabledLanguageDefinitions();
|
||||
|
||||
ide.disposeOnExit(
|
||||
rangeUpdater,
|
||||
languageDefinitions,
|
||||
hatTokenMap,
|
||||
debug,
|
||||
keyboardTargetUpdater,
|
||||
);
|
||||
|
||||
const commandRunnerDecorators: CommandRunnerDecorator[] = [];
|
||||
|
||||
const addCommandRunnerDecorator = (decorator: CommandRunnerDecorator) => {
|
||||
commandRunnerDecorators.push(decorator);
|
||||
};
|
||||
|
||||
let tutorial: Tutorial;
|
||||
if (tutorialContentProvider != null) {
|
||||
const tutorialImpl = new TutorialImpl(
|
||||
hatTokenMap,
|
||||
customSpokenFormGenerator,
|
||||
tutorialContentProvider,
|
||||
);
|
||||
ide.disposeOnExit(tutorialImpl);
|
||||
addCommandRunnerDecorator(tutorialImpl);
|
||||
tutorial = tutorialImpl;
|
||||
} else {
|
||||
tutorial = new DisabledTutorial();
|
||||
}
|
||||
|
||||
ide.disposeOnExit(
|
||||
debug,
|
||||
hatTokenMap,
|
||||
keyboardTargetUpdater,
|
||||
languageDefinitions,
|
||||
rangeUpdater,
|
||||
);
|
||||
|
||||
let previousCommand: Command | undefined = undefined;
|
||||
|
||||
const runCommandClosure = (command: Command) => {
|
||||
@ -141,9 +165,8 @@ export async function createCursorlessEngine({
|
||||
injectIde,
|
||||
runIntegrationTests: () =>
|
||||
runIntegrationTests(treeSitter, languageDefinitions),
|
||||
addCommandRunnerDecorator: (decorator: CommandRunnerDecorator) => {
|
||||
commandRunnerDecorators.push(decorator);
|
||||
},
|
||||
addCommandRunnerDecorator,
|
||||
tutorial,
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,40 @@
|
||||
import {
|
||||
TutorialId,
|
||||
TutorialState,
|
||||
Disposable,
|
||||
ScopeType,
|
||||
} from "@cursorless/common";
|
||||
import { Tutorial } from "../api/Tutorial";
|
||||
|
||||
export class DisabledTutorial implements Tutorial {
|
||||
start(_id: number | TutorialId): Promise<void> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
next(): Promise<void> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
previous(): Promise<void> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
restart(): Promise<void> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
resume(): Promise<void> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
list(): Promise<void> {
|
||||
throw new Error("Method not implemented.");
|
||||
}
|
||||
|
||||
onState(_callback: (state: TutorialState) => void): Disposable {
|
||||
return { dispose: () => {} };
|
||||
}
|
||||
readonly state: TutorialState = { type: "loading" };
|
||||
|
||||
docsOpened(): void {
|
||||
// Do nothing
|
||||
}
|
||||
scopeTypeVisualized(_scopeType: ScopeType | undefined): void {
|
||||
// Do nothing
|
||||
}
|
||||
}
|
@ -59,6 +59,10 @@ export class CustomSpokenFormGeneratorImpl
|
||||
return this.customSpokenForms.spokenFormMap.action[actionId];
|
||||
}
|
||||
|
||||
graphemeToSpokenForm(grapheme: string) {
|
||||
return this.customSpokenForms.spokenFormMap.grapheme[grapheme];
|
||||
}
|
||||
|
||||
getCustomRegexScopeTypes() {
|
||||
return this.customSpokenForms.getCustomRegexScopeTypes();
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
export * from "./testUtil/plainObjectToTarget";
|
||||
export * from "./api/Tutorial";
|
||||
export * from "./core/StoredTargets";
|
||||
export * from "./cursorlessEngine";
|
||||
export * from "./generateSpokenForm/defaultSpokenForms/surroundingPairsDelimiters";
|
||||
|
12
packages/cursorless-engine/src/tutorial/TutorialError.ts
Normal file
12
packages/cursorless-engine/src/tutorial/TutorialError.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export class TutorialError extends Error {
|
||||
public readonly requiresTalonUpdate: boolean;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
{ requiresTalonUpdate }: { requiresTalonUpdate: boolean },
|
||||
) {
|
||||
super(message);
|
||||
|
||||
this.requiresTalonUpdate = requiresTalonUpdate;
|
||||
}
|
||||
}
|
322
packages/cursorless-engine/src/tutorial/TutorialImpl.ts
Normal file
322
packages/cursorless-engine/src/tutorial/TutorialImpl.ts
Normal file
@ -0,0 +1,322 @@
|
||||
import {
|
||||
Disposable,
|
||||
HatTokenMap,
|
||||
Notifier,
|
||||
RawTutorialContent,
|
||||
ReadOnlyHatMap,
|
||||
ScopeType,
|
||||
TextEditor,
|
||||
TutorialContentProvider,
|
||||
TutorialId,
|
||||
TutorialState,
|
||||
} from "@cursorless/common";
|
||||
import { produce } from "immer";
|
||||
import { isEqual } from "lodash-es";
|
||||
import { CommandRunner } from "../CommandRunner";
|
||||
import { CommandRunnerDecorator } from "../api/CursorlessEngineApi";
|
||||
import { Tutorial } from "../api/Tutorial";
|
||||
import { TutorialContent } from "./types/tutorial.types";
|
||||
import { Debouncer } from "../core/Debouncer";
|
||||
import { CustomSpokenFormGeneratorImpl } from "../generateSpokenForm/CustomSpokenFormGeneratorImpl";
|
||||
import { ide } from "../singletons/ide.singleton";
|
||||
import { arePreconditionsMet } from "./arePreconditionsMet";
|
||||
import { setupStep } from "./setupStep";
|
||||
import { loadTutorial } from "./loadTutorial";
|
||||
import { tutorialWrapCommandRunner } from "./tutorialWrapCommandRunner";
|
||||
|
||||
export class TutorialImpl implements Tutorial, CommandRunnerDecorator {
|
||||
/**
|
||||
* The current editor that is being used to display the tutorial, if any.
|
||||
*/
|
||||
private editor?: TextEditor;
|
||||
|
||||
/**
|
||||
* The current state of the tutorial, as exposed by {@link Tutorial.state}.
|
||||
*/
|
||||
private state_: TutorialState = { type: "loading" };
|
||||
|
||||
/**
|
||||
* If {@link state_} is "doingTutorial", this will be the fully parsed current
|
||||
* tutorial, including information about triggers, etc.
|
||||
*/
|
||||
private currentTutorial: TutorialContent | undefined;
|
||||
|
||||
private notifier: Notifier<[TutorialState]> = new Notifier();
|
||||
|
||||
private disposables: Disposable[] = [];
|
||||
|
||||
/**
|
||||
* The raw tutorials that are available to the user. These are the tutorials
|
||||
* that are loaded from disk and have not been parsed yet.
|
||||
*/
|
||||
private rawTutorials!: RawTutorialContent[];
|
||||
|
||||
constructor(
|
||||
private hatTokenMap: HatTokenMap,
|
||||
private customSpokenFormGenerator: CustomSpokenFormGeneratorImpl,
|
||||
private contentProvider: TutorialContentProvider,
|
||||
) {
|
||||
this.setupStep = this.setupStep.bind(this);
|
||||
this.reparseCurrentTutorial = this.reparseCurrentTutorial.bind(this);
|
||||
const debouncer = new Debouncer(() => this.checkPreconditions(), 100);
|
||||
|
||||
this.loadTutorials().then(() => {
|
||||
if (this.state_.type === "loading") {
|
||||
this.setState(this.getPickingTutorialState());
|
||||
}
|
||||
});
|
||||
|
||||
this.disposables.push(
|
||||
ide().onDidChangeActiveTextEditor(debouncer.run),
|
||||
ide().onDidChangeTextDocument(debouncer.run),
|
||||
ide().onDidChangeVisibleTextEditors(debouncer.run),
|
||||
ide().onDidChangeTextEditorSelection(debouncer.run),
|
||||
ide().onDidOpenTextDocument(debouncer.run),
|
||||
ide().onDidCloseTextDocument(debouncer.run),
|
||||
ide().onDidChangeTextEditorVisibleRanges(debouncer.run),
|
||||
customSpokenFormGenerator.onDidChangeCustomSpokenForms(
|
||||
this.reparseCurrentTutorial,
|
||||
),
|
||||
debouncer,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is called when a scope type is visualized. If the current step
|
||||
* is waiting for a visualization of the given scope type, the tutorial will
|
||||
* advance to the next step.
|
||||
* @param scopeType The scope type that was visualized
|
||||
*/
|
||||
scopeTypeVisualized(scopeType: ScopeType | undefined): void {
|
||||
if (this.state_.type === "doingTutorial") {
|
||||
const currentStep = this.currentTutorial!.steps[this.state_.stepNumber];
|
||||
if (
|
||||
currentStep.trigger?.type === "visualize" &&
|
||||
isEqual(currentStep.trigger.scopeType, scopeType)
|
||||
) {
|
||||
this.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async loadTutorials() {
|
||||
this.rawTutorials = await this.contentProvider.loadRawTutorials();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns A {@link TutorialState} object to use when the user is picking a
|
||||
* tutorial to start.
|
||||
*/
|
||||
getPickingTutorialState(): TutorialState {
|
||||
const tutorialProgress = ide().globalState.get("tutorialProgress");
|
||||
|
||||
return {
|
||||
type: "pickingTutorial",
|
||||
tutorials: this.rawTutorials.map((rawContent) => ({
|
||||
id: rawContent.id,
|
||||
title: rawContent.title,
|
||||
version: rawContent.version,
|
||||
stepCount: rawContent.steps.length,
|
||||
currentStep: tutorialProgress[rawContent.id]?.currentStep ?? 0,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
dispose() {
|
||||
for (const disposable of this.disposables) {
|
||||
disposable.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If the tutorial is currently active and we are in a step that is waiting
|
||||
* for a command to be run, we wrap the command runner so that we can
|
||||
* automatically advance to the next step when the expected command is run.
|
||||
*/
|
||||
wrapCommandRunner(
|
||||
_readableHatMap: ReadOnlyHatMap,
|
||||
commandRunner: CommandRunner,
|
||||
): CommandRunner {
|
||||
return tutorialWrapCommandRunner(this, commandRunner, this.currentTutorial);
|
||||
}
|
||||
|
||||
public onState(callback: (state: TutorialState) => void): Disposable {
|
||||
return this.notifier.registerListener(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reparse the current tutorial. This is useful when the user has changed the
|
||||
* spoken forms and we need to reparse the tutorial to use their new spoken
|
||||
* forms.
|
||||
*/
|
||||
private async reparseCurrentTutorial() {
|
||||
if (this.currentTutorial == null || this.state_.type !== "doingTutorial") {
|
||||
return;
|
||||
}
|
||||
|
||||
const tutorialId = this.state_.id;
|
||||
|
||||
const { tutorialContent, state } = await loadTutorial(
|
||||
this.contentProvider,
|
||||
tutorialId,
|
||||
this.customSpokenFormGenerator,
|
||||
this.getRawTutorial(tutorialId),
|
||||
);
|
||||
|
||||
this.currentTutorial = tutorialContent;
|
||||
this.setState(
|
||||
state.hasErrors
|
||||
? {
|
||||
...state,
|
||||
stepNumber: this.state_.stepNumber,
|
||||
}
|
||||
: {
|
||||
...state,
|
||||
stepNumber: this.state_.stepNumber,
|
||||
stepContent: tutorialContent.steps[this.state_.stepNumber].content,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private getRawTutorial(tutorialId: string) {
|
||||
return this.rawTutorials.find(
|
||||
(rawContent) => rawContent.id === tutorialId,
|
||||
)!;
|
||||
}
|
||||
|
||||
async start(tutorialId: TutorialId | number): Promise<void> {
|
||||
if (typeof tutorialId === "number") {
|
||||
tutorialId = this.rawTutorials[tutorialId].id;
|
||||
}
|
||||
|
||||
const { tutorialContent, state } = await loadTutorial(
|
||||
this.contentProvider,
|
||||
tutorialId,
|
||||
this.customSpokenFormGenerator,
|
||||
this.getRawTutorial(tutorialId),
|
||||
);
|
||||
|
||||
this.currentTutorial = tutorialContent;
|
||||
this.setState(state);
|
||||
|
||||
await this.setupStep();
|
||||
}
|
||||
|
||||
docsOpened() {
|
||||
if (this.state_.type === "doingTutorial") {
|
||||
const currentStep = this.currentTutorial!.steps[this.state_.stepNumber];
|
||||
if (currentStep.trigger?.type === "help") {
|
||||
this.next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* When currently doing a tutorial, change to a different step.
|
||||
*
|
||||
* @param getStep Indicates which step to change to
|
||||
*/
|
||||
private async changeStep(
|
||||
getStep: (current: number) => number,
|
||||
): Promise<void> {
|
||||
if (this.state_.type !== "doingTutorial") {
|
||||
throw new Error("Not currently doing a tutorial");
|
||||
}
|
||||
|
||||
if (this.state_.hasErrors) {
|
||||
throw new Error("Please see error message in tutorial sidebar");
|
||||
}
|
||||
|
||||
const newStepNumber = getStep(this.state_.stepNumber);
|
||||
|
||||
if (newStepNumber === this.state_.stepCount || newStepNumber < 0) {
|
||||
await this.list();
|
||||
return;
|
||||
}
|
||||
|
||||
const nextStep = this.currentTutorial!.steps[newStepNumber];
|
||||
|
||||
this.setState({
|
||||
type: "doingTutorial",
|
||||
hasErrors: false,
|
||||
id: this.state_.id,
|
||||
stepNumber: newStepNumber,
|
||||
stepContent: nextStep.content,
|
||||
stepCount: this.state_.stepCount,
|
||||
title: this.state_.title,
|
||||
preConditionsMet: true,
|
||||
});
|
||||
|
||||
await this.setupStep();
|
||||
}
|
||||
|
||||
next() {
|
||||
return this.changeStep((current) => current + 1);
|
||||
}
|
||||
|
||||
previous() {
|
||||
return this.changeStep((current) => current - 1);
|
||||
}
|
||||
|
||||
restart() {
|
||||
return this.changeStep(() => 0);
|
||||
}
|
||||
|
||||
resume() {
|
||||
return this.setupStep();
|
||||
}
|
||||
|
||||
async list() {
|
||||
this.setState(this.getPickingTutorialState());
|
||||
await this.setupStep();
|
||||
}
|
||||
|
||||
private setState(state: TutorialState) {
|
||||
this.state_ = state;
|
||||
|
||||
if (state.type === "doingTutorial") {
|
||||
ide().globalState.set(
|
||||
"tutorialProgress",
|
||||
produce(ide().globalState.get("tutorialProgress"), (draft) => {
|
||||
draft[state.id] = {
|
||||
currentStep: state.stepNumber,
|
||||
version: this.currentTutorial!.version,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
this.notifier.notifyListeners(state);
|
||||
}
|
||||
|
||||
get state() {
|
||||
return this.state_;
|
||||
}
|
||||
|
||||
private async setupStep() {
|
||||
this.editor = await setupStep(
|
||||
this.hatTokenMap,
|
||||
this.editor,
|
||||
this.state,
|
||||
this.currentTutorial,
|
||||
);
|
||||
}
|
||||
|
||||
private async checkPreconditions() {
|
||||
if (this.state_.type === "doingTutorial") {
|
||||
const currentStep = this.currentTutorial!.steps[this.state_.stepNumber];
|
||||
|
||||
const preConditionsMet = await arePreconditionsMet(
|
||||
this.editor,
|
||||
this.hatTokenMap,
|
||||
currentStep,
|
||||
);
|
||||
if (preConditionsMet !== this.state_.preConditionsMet) {
|
||||
this.setState({
|
||||
...this.state_,
|
||||
preConditionsMet,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
195
packages/cursorless-engine/src/tutorial/TutorialStepParser.ts
Normal file
195
packages/cursorless-engine/src/tutorial/TutorialStepParser.ts
Normal file
@ -0,0 +1,195 @@
|
||||
import {
|
||||
TutorialContentProvider,
|
||||
TutorialId,
|
||||
TutorialStepFragment,
|
||||
} from "@cursorless/common";
|
||||
import { TutorialStep } from "./types/tutorial.types";
|
||||
import { parseScopeType } from "../customCommandGrammar/parseCommand";
|
||||
import { CustomSpokenFormGeneratorImpl } from "../generateSpokenForm/CustomSpokenFormGeneratorImpl";
|
||||
import { StepComponent } from "./types/StepComponent";
|
||||
import { getScopeTypeSpokenFormStrict } from "./getScopeTypeSpokenFormStrict";
|
||||
import { specialTerms } from "./specialTerms";
|
||||
import { ActionComponentParser } from "./stepComponentParsers/ActionComponentParser";
|
||||
import { CursorlessCommandComponentParser } from "./stepComponentParsers/CursorlessCommandComponentParser";
|
||||
import { GraphemeComponentParser } from "./stepComponentParsers/GraphemeComponentParser";
|
||||
import { parseSpecialComponent } from "./stepComponentParsers/parseSpecialComponent";
|
||||
import { parseVisualizeComponent } from "./stepComponentParsers/parseVisualizeComponent";
|
||||
|
||||
/**
|
||||
* This is trying to catch occurrences of things like `{command:cloneStateInk.yml}`
|
||||
* or `{action:chuck}` in the tutorial script.
|
||||
*/
|
||||
const componentRegex = /{(\w+):([^}]+)}/g;
|
||||
|
||||
/**
|
||||
* Parses a tutorial step from a raw string. Looks for components in the form
|
||||
* `{action:chuck}`, `{command:cloneStateInk.yml}`, etc and parses them into
|
||||
* {@link StepComponent}s, as well as fragments of plain text in between.
|
||||
*/
|
||||
export class TutorialStepParser {
|
||||
/**
|
||||
* A map of component type to a function that parses the component.
|
||||
*/
|
||||
private componentParsers: Record<
|
||||
string,
|
||||
(arg: string) => Promise<StepComponent>
|
||||
>;
|
||||
|
||||
constructor(
|
||||
contentProvider: TutorialContentProvider,
|
||||
tutorialId: TutorialId,
|
||||
customSpokenFormGenerator: CustomSpokenFormGeneratorImpl,
|
||||
) {
|
||||
this.parseTutorialStep = this.parseTutorialStep.bind(this);
|
||||
|
||||
const cursorlessCommandParser = new CursorlessCommandComponentParser(
|
||||
contentProvider,
|
||||
tutorialId,
|
||||
customSpokenFormGenerator,
|
||||
);
|
||||
|
||||
const actionParser = new ActionComponentParser(customSpokenFormGenerator);
|
||||
|
||||
const graphemeParser = new GraphemeComponentParser(
|
||||
customSpokenFormGenerator,
|
||||
);
|
||||
|
||||
this.componentParsers = {
|
||||
command: (arg) => cursorlessCommandParser.parse(arg),
|
||||
special: parseSpecialComponent,
|
||||
action: (arg) => actionParser.parse(arg),
|
||||
grapheme: (arg) => graphemeParser.parse(arg),
|
||||
|
||||
term: async (arg) => ({
|
||||
content: {
|
||||
type: "term",
|
||||
value: specialTerms[arg as keyof typeof specialTerms],
|
||||
},
|
||||
}),
|
||||
|
||||
scopeType: async (arg) => ({
|
||||
content: {
|
||||
type: "term",
|
||||
value: getScopeTypeSpokenFormStrict(
|
||||
customSpokenFormGenerator,
|
||||
parseScopeType(arg),
|
||||
),
|
||||
},
|
||||
}),
|
||||
|
||||
visualize: (arg) =>
|
||||
parseVisualizeComponent(customSpokenFormGenerator, arg),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the raw content of a tutorial step, parses it into a
|
||||
* {@link TutorialStep} object.
|
||||
*
|
||||
* For example, given `Say {command:takeCap.yml}`, this would return:
|
||||
*
|
||||
* ```json
|
||||
* {
|
||||
* "content": [
|
||||
* [
|
||||
* {
|
||||
* "type": "string",
|
||||
* "value": "Say "
|
||||
* },
|
||||
* {
|
||||
* "type": "command",
|
||||
* "value": "take cap"
|
||||
* }
|
||||
* ]
|
||||
* ],
|
||||
* "initialState": {
|
||||
* "documentContents": "...",
|
||||
* "selections": [ ... ],
|
||||
* "marks": { ... }
|
||||
* },
|
||||
* "languageId": "plaintext",
|
||||
* "trigger": {
|
||||
* "type": "command",
|
||||
* "command": { ... }
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Note that the `initialState`, `languageId`, and `trigger` fiels are optional,
|
||||
* and in this case come from the `takeCap.yml` fixture.
|
||||
*
|
||||
* @param rawContent The raw content of the tutorial step to parse
|
||||
* @returns A {@link TutorialStep} object representing the parsed step
|
||||
*/
|
||||
async parseTutorialStep(rawContent: string): Promise<TutorialStep> {
|
||||
const ret: TutorialStep = {
|
||||
content: [],
|
||||
};
|
||||
|
||||
for (const line of rawContent.split("\n")) {
|
||||
const lineContent: TutorialStepFragment[] = [];
|
||||
let currentIndex = 0;
|
||||
componentRegex.lastIndex = 0;
|
||||
|
||||
for (const {
|
||||
0: { length },
|
||||
1: type,
|
||||
2: arg,
|
||||
index,
|
||||
} of line.matchAll(componentRegex)) {
|
||||
if (index > currentIndex) {
|
||||
lineContent.push({
|
||||
type: "string",
|
||||
value: line.slice(currentIndex, index),
|
||||
});
|
||||
}
|
||||
|
||||
currentIndex = index + length;
|
||||
|
||||
const result = await this.componentParsers[type](arg);
|
||||
|
||||
if (result == null) {
|
||||
throw new Error(`Unknown component type: ${type}`);
|
||||
}
|
||||
|
||||
const { content, ...rest } = result;
|
||||
|
||||
lineContent.push(content);
|
||||
updateStrict<typeof rest>(ret, rest);
|
||||
}
|
||||
|
||||
if (currentIndex < line.length) {
|
||||
lineContent.push({
|
||||
type: "string",
|
||||
value: line.slice(currentIndex),
|
||||
});
|
||||
}
|
||||
|
||||
ret.content.push(lineContent);
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update {@link target} with the non-null values from {@link source}, throwing
|
||||
* an error if a key from {@link source} already exists in {@link target} with a
|
||||
* non-null value.
|
||||
*
|
||||
* @param target The object to update
|
||||
* @param source The object to update from
|
||||
*/
|
||||
function updateStrict<T>(target: T, source: T) {
|
||||
for (const key in source) {
|
||||
if (source[key] == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (target[key] != null) {
|
||||
throw new Error(`Duplicate key: ${key}`);
|
||||
}
|
||||
|
||||
target[key] = source[key];
|
||||
}
|
||||
}
|
@ -0,0 +1,50 @@
|
||||
import {
|
||||
HatTokenMap,
|
||||
TextEditor,
|
||||
plainObjectToSelection,
|
||||
serializedMarksToTokenHats,
|
||||
} from "@cursorless/common";
|
||||
import { isEqual } from "lodash-es";
|
||||
import { TutorialStep } from "./types/tutorial.types";
|
||||
import { ide } from "../singletons/ide.singleton";
|
||||
|
||||
export async function arePreconditionsMet(
|
||||
editor: TextEditor | undefined,
|
||||
hatTokenMap: HatTokenMap,
|
||||
{ initialState: snapshot, languageId }: TutorialStep,
|
||||
): Promise<boolean> {
|
||||
if (snapshot == null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (ide().activeTextEditor !== editor) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (editor == null || editor.document.languageId !== languageId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (editor.document.getText() !== snapshot.documentContents) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
!isEqual(editor.selections, snapshot.selections.map(plainObjectToSelection))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const readableHatMap = await hatTokenMap.getReadableMap(false);
|
||||
for (const mark of serializedMarksToTokenHats(snapshot.marks, editor)) {
|
||||
if (
|
||||
!readableHatMap
|
||||
.getToken(mark.hatStyle, mark.grapheme)
|
||||
?.range.isRangeEqual(mark.hatRange)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
@ -0,0 +1,19 @@
|
||||
import { ScopeType } from "@cursorless/common";
|
||||
import { CustomSpokenFormGeneratorImpl } from "../generateSpokenForm/CustomSpokenFormGeneratorImpl";
|
||||
import { TutorialError } from "./TutorialError";
|
||||
|
||||
export function getScopeTypeSpokenFormStrict(
|
||||
customSpokenFormGenerator: CustomSpokenFormGeneratorImpl,
|
||||
scopeType: ScopeType,
|
||||
) {
|
||||
const spokenForm = customSpokenFormGenerator.scopeTypeToSpokenForm(scopeType);
|
||||
|
||||
if (spokenForm.type === "error") {
|
||||
throw new TutorialError(
|
||||
`Error while processing spoken form for scope type: ${spokenForm.reason}`,
|
||||
{ requiresTalonUpdate: spokenForm.requiresTalonUpdate },
|
||||
);
|
||||
}
|
||||
|
||||
return spokenForm.spokenForms[0];
|
||||
}
|
71
packages/cursorless-engine/src/tutorial/loadTutorial.ts
Normal file
71
packages/cursorless-engine/src/tutorial/loadTutorial.ts
Normal file
@ -0,0 +1,71 @@
|
||||
import {
|
||||
RawTutorialContent,
|
||||
TutorialContentProvider,
|
||||
TutorialId,
|
||||
TutorialState,
|
||||
} from "@cursorless/common";
|
||||
import { TutorialContent } from "./types/tutorial.types";
|
||||
import { CustomSpokenFormGeneratorImpl } from "../generateSpokenForm/CustomSpokenFormGeneratorImpl";
|
||||
import { ide } from "../singletons/ide.singleton";
|
||||
import { TutorialError } from "./TutorialError";
|
||||
import { TutorialStepParser } from "./TutorialStepParser";
|
||||
|
||||
export async function loadTutorial(
|
||||
contentProvider: TutorialContentProvider,
|
||||
tutorialId: TutorialId,
|
||||
customSpokenFormGenerator: CustomSpokenFormGeneratorImpl,
|
||||
rawContent: RawTutorialContent,
|
||||
) {
|
||||
const parser = new TutorialStepParser(
|
||||
contentProvider,
|
||||
tutorialId,
|
||||
customSpokenFormGenerator,
|
||||
);
|
||||
|
||||
let tutorialContent: TutorialContent;
|
||||
let state: TutorialState;
|
||||
|
||||
try {
|
||||
tutorialContent = {
|
||||
title: rawContent.title,
|
||||
version: rawContent.version,
|
||||
steps: await Promise.all(rawContent.steps.map(parser.parseTutorialStep)),
|
||||
};
|
||||
|
||||
let stepNumber =
|
||||
ide().globalState.get("tutorialProgress")[tutorialId]?.currentStep ?? 0;
|
||||
|
||||
if (stepNumber >= tutorialContent.steps.length - 1) {
|
||||
stepNumber = 0;
|
||||
}
|
||||
|
||||
state = {
|
||||
type: "doingTutorial",
|
||||
hasErrors: false,
|
||||
id: tutorialId,
|
||||
stepNumber,
|
||||
stepContent: tutorialContent.steps[stepNumber].content,
|
||||
stepCount: tutorialContent.steps.length,
|
||||
title: tutorialContent.title,
|
||||
preConditionsMet: true,
|
||||
};
|
||||
} catch (err) {
|
||||
tutorialContent = {
|
||||
title: rawContent.title,
|
||||
steps: [],
|
||||
version: rawContent.version,
|
||||
};
|
||||
state = {
|
||||
type: "doingTutorial",
|
||||
hasErrors: true,
|
||||
id: tutorialId,
|
||||
stepNumber: 0,
|
||||
title: tutorialContent.title,
|
||||
preConditionsMet: true,
|
||||
requiresTalonUpdate:
|
||||
err instanceof TutorialError && err.requiresTalonUpdate,
|
||||
};
|
||||
}
|
||||
|
||||
return { tutorialContent, state };
|
||||
}
|
117
packages/cursorless-engine/src/tutorial/setupStep.ts
Normal file
117
packages/cursorless-engine/src/tutorial/setupStep.ts
Normal file
@ -0,0 +1,117 @@
|
||||
import {
|
||||
HatTokenMap,
|
||||
TestCaseSnapshot,
|
||||
TextEditor,
|
||||
TutorialState,
|
||||
plainObjectToRange,
|
||||
plainObjectToSelection,
|
||||
serializedMarksToTokenHats,
|
||||
toCharacterRange,
|
||||
} from "@cursorless/common";
|
||||
import { ide } from "../singletons/ide.singleton";
|
||||
import { TutorialContent } from "./types/tutorial.types";
|
||||
|
||||
const HIGHLIGHT_COLOR = "highlight0";
|
||||
|
||||
/**
|
||||
* Set up the current step. For example, if the current step requires that the
|
||||
* user has an editor open with certain content, this function will ensure that
|
||||
* the editor is open and has the correct content.
|
||||
*
|
||||
* Initially tries to reuse the existing editor if it's already open. If that
|
||||
* fails, it will try again with a new editor.
|
||||
*
|
||||
* @param hatTokenMap The hat token map to use for allocating hats.
|
||||
* @param editor The current editor, if any.
|
||||
* @param state The current tutorial state.
|
||||
* @param currentTutorial The current tutorial, if any.
|
||||
* @returns The editor that was set up, or `undefined` if no editor was set up.
|
||||
* If the current editor was reused, it will be returned.
|
||||
*/
|
||||
export async function setupStep(
|
||||
hatTokenMap: HatTokenMap,
|
||||
editor: TextEditor | undefined,
|
||||
state: TutorialState,
|
||||
currentTutorial: TutorialContent | undefined,
|
||||
): Promise<TextEditor | undefined> {
|
||||
if (state.type !== "doingTutorial") {
|
||||
if (editor != null) {
|
||||
ide().setHighlightRanges(HIGHLIGHT_COLOR, editor, []);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { initialState: snapshot, languageId = "plaintext" } =
|
||||
currentTutorial!.steps[state.stepNumber];
|
||||
|
||||
if (snapshot == null) {
|
||||
if (editor != null) {
|
||||
ide().setHighlightRanges(HIGHLIGHT_COLOR, editor, []);
|
||||
}
|
||||
return editor;
|
||||
}
|
||||
|
||||
return await applySnapshot(hatTokenMap, editor, snapshot, languageId);
|
||||
}
|
||||
|
||||
async function applySnapshot(
|
||||
hatTokenMap: HatTokenMap,
|
||||
editor: TextEditor | undefined,
|
||||
snapshot: TestCaseSnapshot,
|
||||
languageId: string,
|
||||
) {
|
||||
const retry = editor != null;
|
||||
|
||||
try {
|
||||
if (editor == null) {
|
||||
editor = await ide().openUntitledTextDocument({
|
||||
content: snapshot.documentContents,
|
||||
language: languageId,
|
||||
});
|
||||
}
|
||||
|
||||
const editableEditor = ide().getEditableTextEditor(editor);
|
||||
|
||||
if (editableEditor.document.languageId !== languageId) {
|
||||
throw new Error(
|
||||
`Expected language id ${languageId}, but got ${editableEditor.document.languageId}`,
|
||||
);
|
||||
}
|
||||
|
||||
await editableEditor.edit([
|
||||
{
|
||||
range: editableEditor.document.range,
|
||||
text: snapshot.documentContents,
|
||||
isReplace: true,
|
||||
},
|
||||
]);
|
||||
|
||||
// Ensure that the expected cursor/selections are present
|
||||
await editableEditor.setSelections(
|
||||
snapshot.selections.map(plainObjectToSelection),
|
||||
);
|
||||
|
||||
// Ensure that the expected hats are present
|
||||
await hatTokenMap.allocateHats(
|
||||
serializedMarksToTokenHats(snapshot.marks, editor),
|
||||
);
|
||||
|
||||
ide().setHighlightRanges(
|
||||
HIGHLIGHT_COLOR,
|
||||
editor,
|
||||
Object.values(snapshot.marks ?? {}).map((range) =>
|
||||
toCharacterRange(plainObjectToRange(range)),
|
||||
),
|
||||
);
|
||||
|
||||
await editableEditor.focus();
|
||||
|
||||
return editor;
|
||||
} catch (err) {
|
||||
if (retry) {
|
||||
return await applySnapshot(hatTokenMap, undefined, snapshot, languageId);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
7
packages/cursorless-engine/src/tutorial/specialTerms.ts
Normal file
7
packages/cursorless-engine/src/tutorial/specialTerms.ts
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Special terms used in spoken forms that are referred to in the tutorial, but
|
||||
* don't constitute a complete command.
|
||||
*/
|
||||
export const specialTerms = {
|
||||
visualize: "visualize",
|
||||
};
|
@ -0,0 +1,48 @@
|
||||
import { ActionType, actionNames } from "@cursorless/common";
|
||||
import { CustomSpokenFormGeneratorImpl } from "../../generateSpokenForm/CustomSpokenFormGeneratorImpl";
|
||||
import { defaultSpokenFormMap } from "../../spokenForms/defaultSpokenFormMap";
|
||||
import { StepComponent, StepComponentParser } from "../types/StepComponent";
|
||||
|
||||
/**
|
||||
* Parses components of the form `{action:chuck}`.
|
||||
*/
|
||||
export class ActionComponentParser implements StepComponentParser {
|
||||
private actionMap: Record<string, ActionType> = {};
|
||||
|
||||
constructor(
|
||||
private customSpokenFormGenerator: CustomSpokenFormGeneratorImpl,
|
||||
) {
|
||||
for (const actionName of actionNames) {
|
||||
const { spokenForms } = defaultSpokenFormMap.action[actionName];
|
||||
for (const spokenForm of spokenForms) {
|
||||
this.actionMap[spokenForm] = actionName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async parse(arg: string): Promise<StepComponent> {
|
||||
return {
|
||||
content: {
|
||||
type: "term",
|
||||
value: this.getActionSpokenForm(this.parseActionName(arg)),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private getActionSpokenForm(actionId: ActionType) {
|
||||
const spokenForm =
|
||||
this.customSpokenFormGenerator.actionIdToSpokenForm(actionId);
|
||||
|
||||
return spokenForm.spokenForms[0];
|
||||
}
|
||||
|
||||
private parseActionName(arg: string): ActionType {
|
||||
const actionId = this.actionMap[arg];
|
||||
|
||||
if (actionId == null) {
|
||||
throw new Error(`Unknown action: ${arg}`);
|
||||
}
|
||||
|
||||
return actionId;
|
||||
}
|
||||
}
|
@ -0,0 +1,56 @@
|
||||
import {
|
||||
CommandComplete,
|
||||
TutorialContentProvider,
|
||||
TutorialId,
|
||||
} from "@cursorless/common";
|
||||
import { canonicalizeAndValidateCommand } from "../../core/commandVersionUpgrades/canonicalizeAndValidateCommand";
|
||||
import { CustomSpokenFormGeneratorImpl } from "../../generateSpokenForm/CustomSpokenFormGeneratorImpl";
|
||||
import { StepComponent, StepComponentParser } from "../types/StepComponent";
|
||||
import { TutorialError } from "../TutorialError";
|
||||
|
||||
/**
|
||||
* Parses components of the form `{command:takeNear.yml}`. The argument
|
||||
* (`takeNear.yml`) is the name of a fixture file in the tutorial directory.
|
||||
*/
|
||||
export class CursorlessCommandComponentParser implements StepComponentParser {
|
||||
constructor(
|
||||
private contentProvider: TutorialContentProvider,
|
||||
private tutorialId: TutorialId,
|
||||
private customSpokenFormGenerator: CustomSpokenFormGeneratorImpl,
|
||||
) {}
|
||||
|
||||
async parse(arg: string): Promise<StepComponent> {
|
||||
const fixture = await this.contentProvider.loadFixture(
|
||||
this.tutorialId,
|
||||
arg,
|
||||
);
|
||||
const command = canonicalizeAndValidateCommand(fixture.command);
|
||||
|
||||
return {
|
||||
initialState: fixture.initialState,
|
||||
languageId: fixture.languageId,
|
||||
trigger: {
|
||||
type: "command",
|
||||
command,
|
||||
},
|
||||
content: {
|
||||
type: "command",
|
||||
value: this.getCommandSpokenForm(command),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private getCommandSpokenForm(command: CommandComplete) {
|
||||
const spokenForm =
|
||||
this.customSpokenFormGenerator.commandToSpokenForm(command);
|
||||
|
||||
if (spokenForm.type === "error") {
|
||||
throw new TutorialError(
|
||||
`Error while processing spoken form for command: ${spokenForm.reason}`,
|
||||
{ requiresTalonUpdate: spokenForm.requiresTalonUpdate },
|
||||
);
|
||||
}
|
||||
|
||||
return spokenForm.spokenForms[0];
|
||||
}
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
import { CustomSpokenFormGeneratorImpl } from "../../generateSpokenForm/CustomSpokenFormGeneratorImpl";
|
||||
import { StepComponent, StepComponentParser } from "../types/StepComponent";
|
||||
|
||||
/**
|
||||
* Parses components of the form `{grapheme:c}`. Used to refer to the user's
|
||||
* custom spoken form for a grapheme.
|
||||
*/
|
||||
export class GraphemeComponentParser implements StepComponentParser {
|
||||
constructor(
|
||||
private customSpokenFormGenerator: CustomSpokenFormGeneratorImpl,
|
||||
) {}
|
||||
|
||||
async parse(arg: string): Promise<StepComponent> {
|
||||
return {
|
||||
content: {
|
||||
type: "term",
|
||||
value: this.getGraphemeSpokenForm(arg),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private getGraphemeSpokenForm(grapheme: string) {
|
||||
const spokenForm =
|
||||
this.customSpokenFormGenerator.graphemeToSpokenForm(grapheme);
|
||||
|
||||
return spokenForm.spokenForms[0];
|
||||
}
|
||||
}
|
@ -0,0 +1,40 @@
|
||||
import { TutorialStepTrigger } from "../types/TutorialStepTrigger";
|
||||
import { StepComponent } from "../types/StepComponent";
|
||||
|
||||
const SPECIAL_COMMANDS = {
|
||||
help: "cursorless help",
|
||||
next: "tutorial next",
|
||||
visualizeNothing: "visualize nothing",
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses components of the form `{special:help}`. These are special commands
|
||||
* that don't correspond to any cursorless command.
|
||||
*/
|
||||
export async function parseSpecialComponent(
|
||||
arg: string,
|
||||
): Promise<StepComponent> {
|
||||
let trigger: TutorialStepTrigger | undefined = undefined;
|
||||
|
||||
switch (arg) {
|
||||
case "help":
|
||||
trigger = {
|
||||
type: "help",
|
||||
};
|
||||
break;
|
||||
case "visualizeNothing":
|
||||
trigger = {
|
||||
type: "visualize",
|
||||
scopeType: undefined,
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
content: {
|
||||
type: "command",
|
||||
value: SPECIAL_COMMANDS[arg as keyof typeof SPECIAL_COMMANDS],
|
||||
},
|
||||
trigger,
|
||||
};
|
||||
}
|
@ -0,0 +1,28 @@
|
||||
import { parseScopeType } from "../../customCommandGrammar/parseCommand";
|
||||
import { CustomSpokenFormGeneratorImpl } from "../../generateSpokenForm/CustomSpokenFormGeneratorImpl";
|
||||
import { StepComponent } from "../types/StepComponent";
|
||||
import { getScopeTypeSpokenFormStrict } from "../getScopeTypeSpokenFormStrict";
|
||||
import { specialTerms } from "../specialTerms";
|
||||
|
||||
/**
|
||||
* Parses components of the form `{visualize:funk}`. Displays the command for
|
||||
* visualizing a scope type and causes the step to automatically advance when
|
||||
* the user visualizes the scope type.
|
||||
*/
|
||||
export async function parseVisualizeComponent(
|
||||
customSpokenFormGenerator: CustomSpokenFormGeneratorImpl,
|
||||
arg: string,
|
||||
): Promise<StepComponent> {
|
||||
const scopeType = parseScopeType(arg);
|
||||
|
||||
return {
|
||||
content: {
|
||||
type: "command",
|
||||
value: `${specialTerms.visualize} ${getScopeTypeSpokenFormStrict(customSpokenFormGenerator, scopeType)}`,
|
||||
},
|
||||
trigger: {
|
||||
type: "visualize",
|
||||
scopeType,
|
||||
},
|
||||
};
|
||||
}
|
@ -0,0 +1,35 @@
|
||||
import { CommandComplete } from "@cursorless/common";
|
||||
import { isEqual } from "lodash-es";
|
||||
import { CommandRunner } from "../CommandRunner";
|
||||
import { Tutorial } from "../api/Tutorial";
|
||||
import { TutorialContent } from "./types/tutorial.types";
|
||||
|
||||
export function tutorialWrapCommandRunner(
|
||||
tutorial: Tutorial,
|
||||
commandRunner: CommandRunner,
|
||||
currentTutorial: TutorialContent | undefined,
|
||||
): CommandRunner {
|
||||
if (tutorial.state.type !== "doingTutorial") {
|
||||
return commandRunner;
|
||||
}
|
||||
|
||||
const currentStep = currentTutorial?.steps[tutorial.state.stepNumber];
|
||||
|
||||
if (currentStep?.trigger?.type !== "command") {
|
||||
return commandRunner;
|
||||
}
|
||||
|
||||
const trigger = currentStep.trigger;
|
||||
|
||||
return {
|
||||
run: async (commandComplete: CommandComplete) => {
|
||||
const returnValue = await commandRunner.run(commandComplete);
|
||||
|
||||
if (isEqual(trigger.command.action, commandComplete.action)) {
|
||||
await tutorial.next();
|
||||
}
|
||||
|
||||
return returnValue;
|
||||
},
|
||||
};
|
||||
}
|
@ -0,0 +1,32 @@
|
||||
import { TutorialStepFragment } from "@cursorless/common";
|
||||
import { TutorialStep } from "./tutorial.types";
|
||||
|
||||
/**
|
||||
* Represents a `{foo:bar}` component in a tutorial step, eg `{action:chuck}`.
|
||||
* It will be dynamically rendered when the tutorial step is displayed. For
|
||||
* example, the above component would render as the user's custom spoken form
|
||||
* for the `chuck` action, surrounded by quotes.
|
||||
*
|
||||
* Note that in addition to the {@link content} to display, the component can
|
||||
* optionally set other fields on the step, such as a {@link trigger} to advance
|
||||
* to the next step.
|
||||
*/
|
||||
export interface StepComponent extends Omit<TutorialStep, "content"> {
|
||||
/**
|
||||
* The content of the component to display in the tutorial step.
|
||||
*/
|
||||
content: TutorialStepFragment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for classes that parse a string into a {@link StepComponent}.
|
||||
*/
|
||||
export interface StepComponentParser {
|
||||
/**
|
||||
* Parses a string into a {@link StepComponent}.
|
||||
*
|
||||
* @param arg The string to parse. This will be between the `:` and `}` in a
|
||||
* component, eg `chuck` in `{action:chuck}`.
|
||||
*/
|
||||
parse(arg: string): Promise<StepComponent>;
|
||||
}
|
@ -0,0 +1,41 @@
|
||||
import { CommandComplete, ScopeType } from "@cursorless/common";
|
||||
|
||||
/**
|
||||
* Advance to the next step when the user completes a command
|
||||
*/
|
||||
export interface CommandTutorialStepTrigger {
|
||||
type: "command";
|
||||
|
||||
/**
|
||||
* The command we're waiting for to advance to the next step
|
||||
*/
|
||||
command: CommandComplete;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance to the next step when the user completes a command
|
||||
*/
|
||||
export interface CommandTutorialVisualizeTrigger {
|
||||
type: "visualize";
|
||||
|
||||
/**
|
||||
* The command we're waiting for to advance to the next step
|
||||
*/
|
||||
scopeType: ScopeType | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance to the next step when the user opens the documentation
|
||||
*/
|
||||
export interface HelpTutorialStepTrigger {
|
||||
type: "help";
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a trigger that advances to the next step in a tutorial when a
|
||||
* certain condition is met.
|
||||
*/
|
||||
export type TutorialStepTrigger =
|
||||
| CommandTutorialStepTrigger
|
||||
| CommandTutorialVisualizeTrigger
|
||||
| HelpTutorialStepTrigger;
|
@ -0,0 +1,47 @@
|
||||
import { TestCaseSnapshot, TutorialStepFragment } from "@cursorless/common";
|
||||
import { TutorialStepTrigger } from "./TutorialStepTrigger";
|
||||
|
||||
/**
|
||||
* Represents the content of a tutorial. Used internally by the tutorial
|
||||
* component to control the tutorial.
|
||||
*/
|
||||
export interface TutorialContent {
|
||||
/**
|
||||
* The title of the tutorial
|
||||
*/
|
||||
title: string;
|
||||
|
||||
/**
|
||||
* The version of the tutorial
|
||||
*/
|
||||
version: number;
|
||||
|
||||
/**
|
||||
* The steps of the current tutorial
|
||||
*/
|
||||
steps: Array<TutorialStep>;
|
||||
}
|
||||
|
||||
export interface TutorialStep {
|
||||
/**
|
||||
* The content of the current step. Each element in the array represents a
|
||||
* paragraph in the tutorial step.
|
||||
*/
|
||||
content: TutorialStepFragment[][];
|
||||
|
||||
/**
|
||||
* The path to the yaml file that should be used to setup the current step (if
|
||||
* any). The path is relative to the tutorial directory for the given tutorial.
|
||||
*/
|
||||
initialState?: TestCaseSnapshot;
|
||||
|
||||
/**
|
||||
* The language id to use when opening the editor for the current step
|
||||
*/
|
||||
languageId?: string;
|
||||
|
||||
/**
|
||||
* When this happens, advance to the next step
|
||||
*/
|
||||
trigger?: TutorialStepTrigger;
|
||||
}
|
@ -16,9 +16,17 @@ let retryCount = -1;
|
||||
*/
|
||||
let previousTestTitle = "";
|
||||
|
||||
export function endToEndTestSetup(suite: Mocha.Suite) {
|
||||
suite.timeout("100s");
|
||||
suite.retries(5);
|
||||
interface EndToEndTestSetupOpts {
|
||||
retries?: number;
|
||||
timeout?: string | number;
|
||||
}
|
||||
|
||||
export function endToEndTestSetup(
|
||||
suite: Mocha.Suite,
|
||||
{ retries = 5, timeout = "100s" }: EndToEndTestSetupOpts = {},
|
||||
) {
|
||||
suite.timeout(timeout);
|
||||
suite.retries(retries);
|
||||
|
||||
let ide: IDE;
|
||||
let injectIde: (ide: IDE) => void;
|
||||
|
@ -0,0 +1,229 @@
|
||||
import {
|
||||
LATEST_VERSION,
|
||||
SpyIDE,
|
||||
TestCaseFixtureLegacy,
|
||||
asyncSafety,
|
||||
getSnapshotForComparison,
|
||||
sleep,
|
||||
} from "@cursorless/common";
|
||||
import { getRecordedTestsDirPath, loadFixture } from "@cursorless/node-common";
|
||||
import {
|
||||
getCursorlessApi,
|
||||
runCursorlessCommand,
|
||||
} from "@cursorless/vscode-common";
|
||||
import assert from "node:assert";
|
||||
import path from "path";
|
||||
import sinon from "sinon";
|
||||
import { commands } from "vscode";
|
||||
import { endToEndTestSetup } from "../../endToEndTestSetup";
|
||||
import { isEqual, uniqWith } from "lodash-es";
|
||||
|
||||
suite("tutorial", async function () {
|
||||
// Retry doesn't make sense because we need to capture initial load events of
|
||||
// the webview.
|
||||
const { getSpy } = endToEndTestSetup(this, { retries: 0 });
|
||||
|
||||
test(
|
||||
"basic",
|
||||
asyncSafety(() => runBasicTutorialTest(getSpy()!)),
|
||||
);
|
||||
});
|
||||
|
||||
const BASICS_TUTORIAL_ID = "unit-1-basics";
|
||||
|
||||
async function runBasicTutorialTest(spyIde: SpyIDE) {
|
||||
const cursorlessApi = await getCursorlessApi();
|
||||
const { hatTokenMap, takeSnapshot, getTutorialWebviewEventLog, vscodeApi } =
|
||||
cursorlessApi.testHelpers!;
|
||||
const commandsRun: string[] = [];
|
||||
sinon.replace(
|
||||
vscodeApi.commands,
|
||||
"executeCommand",
|
||||
<T>(command: string, ...args: any[]): Thenable<T> => {
|
||||
commandsRun.push(command);
|
||||
return commands.executeCommand(command, ...args);
|
||||
},
|
||||
);
|
||||
const tutorialDirectory = path.join(
|
||||
getRecordedTestsDirPath(),
|
||||
"tutorial",
|
||||
BASICS_TUTORIAL_ID,
|
||||
);
|
||||
|
||||
const fixtures = await Promise.all(
|
||||
["takeCap.yml", "takeBlueSun.yml", "takeHarpPastDrum.yml"].map((name) =>
|
||||
loadFixture(path.join(tutorialDirectory, name)),
|
||||
),
|
||||
);
|
||||
|
||||
const checkStepSetup = async (fixture: TestCaseFixtureLegacy) => {
|
||||
assert.deepStrictEqual(
|
||||
await getSnapshotForComparison(
|
||||
fixture.initialState,
|
||||
await hatTokenMap.getReadableMap(false),
|
||||
spyIde,
|
||||
takeSnapshot,
|
||||
),
|
||||
fixture.initialState,
|
||||
"Unexpected final state",
|
||||
);
|
||||
};
|
||||
|
||||
// Test starting tutorial
|
||||
await commands.executeCommand(
|
||||
"cursorless.tutorial.start",
|
||||
BASICS_TUTORIAL_ID,
|
||||
);
|
||||
await checkStepSetup(fixtures[0]);
|
||||
|
||||
// Allow for debounce
|
||||
await sleep(350);
|
||||
|
||||
// We allow duplicate messages because they're idempotent. Not sure why some
|
||||
// platforms get the init message twice but it doesn't matter.
|
||||
const result = uniqWith(getTutorialWebviewEventLog(), isEqual);
|
||||
assert.deepStrictEqual(
|
||||
result,
|
||||
[
|
||||
// This is the initial message that the webview sends to the extension.
|
||||
// Seeing this means that the javascript in the webview successfully loaded.
|
||||
{
|
||||
type: "messageReceived",
|
||||
data: {
|
||||
type: "getInitialState",
|
||||
},
|
||||
},
|
||||
|
||||
// This is the response from the extension to the webview's initial message.
|
||||
{
|
||||
type: "messageSent",
|
||||
data: {
|
||||
type: "doingTutorial",
|
||||
hasErrors: false,
|
||||
id: "unit-1-basics",
|
||||
stepNumber: 0,
|
||||
stepContent: [
|
||||
[
|
||||
{
|
||||
type: "string",
|
||||
value: "Say ",
|
||||
},
|
||||
{
|
||||
type: "command",
|
||||
value: "take cap",
|
||||
},
|
||||
],
|
||||
],
|
||||
stepCount: 11,
|
||||
title: "Introduction",
|
||||
preConditionsMet: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
JSON.stringify(result, null, 2),
|
||||
);
|
||||
|
||||
// Check that we focus the tutorial webview when the user starts the tutorial
|
||||
assert(commandsRun.includes("cursorless.tutorial.focus"));
|
||||
|
||||
// Check that it doesn't auto-advance for incorrect command
|
||||
await runNoOpCursorlessCommand();
|
||||
await checkStepSetup(fixtures[0]);
|
||||
|
||||
// Test that we detect when prerequisites are no longer met
|
||||
// "chuck file"
|
||||
await runCursorlessCommand({
|
||||
version: LATEST_VERSION,
|
||||
action: {
|
||||
name: "remove",
|
||||
target: {
|
||||
type: "primitive",
|
||||
modifiers: [
|
||||
{ type: "containingScope", scopeType: { type: "document" } },
|
||||
],
|
||||
},
|
||||
},
|
||||
usePrePhraseSnapshot: false,
|
||||
});
|
||||
|
||||
// Allow for debounce
|
||||
await sleep(150);
|
||||
|
||||
// We allow duplicate messages because they're idempotent. Not sure why some
|
||||
// platforms get the init message twice but it doesn't matter.
|
||||
const log = uniqWith(getTutorialWebviewEventLog(), isEqual);
|
||||
assert.equal(log.length, 3, JSON.stringify(log, null, 2));
|
||||
const lastMessage = log[log.length - 1];
|
||||
assert(
|
||||
lastMessage.type === "messageSent" &&
|
||||
lastMessage.data.preConditionsMet === false,
|
||||
);
|
||||
|
||||
// Test resuming tutorial
|
||||
await commands.executeCommand("cursorless.tutorial.resume");
|
||||
await checkStepSetup(fixtures[0]);
|
||||
|
||||
// Test automatic advancing
|
||||
await runCursorlessCommand(fixtures[0].command);
|
||||
await checkStepSetup(fixtures[1]);
|
||||
|
||||
// Test restarting tutorial
|
||||
await commands.executeCommand("cursorless.tutorial.restart");
|
||||
await checkStepSetup(fixtures[0]);
|
||||
|
||||
// Test manual advancing
|
||||
await commands.executeCommand("cursorless.tutorial.next");
|
||||
await commands.executeCommand("cursorless.tutorial.next");
|
||||
await checkStepSetup(fixtures[2]);
|
||||
|
||||
// Test manual retreating
|
||||
await commands.executeCommand("cursorless.tutorial.previous");
|
||||
await checkStepSetup(fixtures[1]);
|
||||
|
||||
// Test listing tutorials
|
||||
await commands.executeCommand("cursorless.tutorial.list");
|
||||
assert.deepStrictEqual(getTutorialWebviewEventLog().slice(-2), [
|
||||
{
|
||||
type: "messageSent",
|
||||
data: {
|
||||
type: "pickingTutorial",
|
||||
tutorials: [
|
||||
{
|
||||
id: "unit-1-basics",
|
||||
title: "Introduction",
|
||||
version: 0,
|
||||
stepCount: 11,
|
||||
currentStep: 1,
|
||||
},
|
||||
{
|
||||
id: "unit-2-basic-coding",
|
||||
title: "Basic coding",
|
||||
version: 0,
|
||||
stepCount: 11,
|
||||
currentStep: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{ type: "viewShown", preserveFocus: true },
|
||||
]);
|
||||
}
|
||||
|
||||
// This is a cursorless command that does nothing. It's useful for testing
|
||||
// that the tutorial doesn't auto-advance when the user does something that
|
||||
// isn't part of the tutorial.
|
||||
const runNoOpCursorlessCommand = () =>
|
||||
runCursorlessCommand({
|
||||
version: LATEST_VERSION,
|
||||
action: {
|
||||
name: "setSelection",
|
||||
target: {
|
||||
type: "primitive",
|
||||
mark: {
|
||||
type: "cursor",
|
||||
},
|
||||
modifiers: [{ type: "toRawSelection" }],
|
||||
},
|
||||
},
|
||||
usePrePhraseSnapshot: false,
|
||||
});
|
12
packages/cursorless-vscode-tutorial-webview/README.md
Normal file
12
packages/cursorless-vscode-tutorial-webview/README.md
Normal file
@ -0,0 +1,12 @@
|
||||
# Cursorless VSCode Tutorial Webview
|
||||
|
||||
This package holds the Javascript and CSS for the webview that is displayed when
|
||||
the user opens any tutorial in VSCode. It is rendered in the sidebar.
|
||||
|
||||
## Development
|
||||
|
||||
To enable hot reloading, run the following command:
|
||||
|
||||
```bash
|
||||
pnpm watch
|
||||
```
|
35
packages/cursorless-vscode-tutorial-webview/package.json
Normal file
35
packages/cursorless-vscode-tutorial-webview/package.json
Normal file
@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@cursorless/cursorless-vscode-tutorial-webview",
|
||||
"version": "1.0.0",
|
||||
"description": "Contains the VSCode webview frontend for the Cursorless tutorial",
|
||||
"private": true,
|
||||
"main": "./out/index.js",
|
||||
"scripts": {
|
||||
"compile:tsc": "tsc --build",
|
||||
"compile": "pnpm compile:tsc",
|
||||
"watch:tsc": "pnpm compile:tsc --watch",
|
||||
"watch:esbuild": "pnpm build:esbuild --watch",
|
||||
"watch:tailwind": "pnpm build:tailwind --watch",
|
||||
"watch": "pnpm run --filter @cursorless/cursorless-vscode-tutorial-webview --parallel '/^watch:.*/'",
|
||||
"build:esbuild": "esbuild ./src/index.tsx --sourcemap --format=cjs --bundle --outfile=./out/index.js",
|
||||
"build:tailwind": "tailwindcss -i ./src/index.css -o ./out/index.css",
|
||||
"build": "pnpm build:esbuild --minify && pnpm build:tailwind --minify",
|
||||
"build:dev": "pnpm build:esbuild && pnpm build:tailwind",
|
||||
"clean": "rm -rf ./out tsconfig.tsbuildinfo ./dist ./build"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@types/react": "18.2.71",
|
||||
"@types/react-dom": "18.2.22",
|
||||
"@types/vscode-webview": "1.57.5",
|
||||
"tailwindcss": "3.4.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cursorless/common": "workspace:*",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0"
|
||||
}
|
||||
}
|
101
packages/cursorless-vscode-tutorial-webview/src/App.tsx
Normal file
101
packages/cursorless-vscode-tutorial-webview/src/App.tsx
Normal file
@ -0,0 +1,101 @@
|
||||
import { TutorialState } from "@cursorless/common";
|
||||
import { useEffect, useState, type FunctionComponent } from "react";
|
||||
import { WebviewApi } from "vscode-webview";
|
||||
import { TutorialStep } from "./TutorialStep";
|
||||
import { Command } from "./Command";
|
||||
|
||||
interface Props {
|
||||
vscode: WebviewApi<undefined>;
|
||||
}
|
||||
|
||||
export const App: FunctionComponent<Props> = ({ vscode }) => {
|
||||
const [state, setState] = useState<TutorialState>();
|
||||
|
||||
useEffect(() => {
|
||||
// Handle messages sent from the extension to the webview
|
||||
window.addEventListener(
|
||||
"message",
|
||||
({ data: newState }: { data: TutorialState }) => {
|
||||
setState(newState);
|
||||
},
|
||||
);
|
||||
|
||||
vscode.postMessage({ type: "getInitialState" });
|
||||
}, []);
|
||||
|
||||
if (state == null) {
|
||||
// Just show nothing while we're waiting for initial state
|
||||
return <></>;
|
||||
}
|
||||
|
||||
switch (state.type) {
|
||||
case "pickingTutorial":
|
||||
return (
|
||||
<div>
|
||||
<p>
|
||||
To start a tutorial, say <Command spokenForm="tutorial <number>" />,
|
||||
or click one of the following tutorials:
|
||||
</p>
|
||||
<ol className="mt-2 list-decimal">
|
||||
{state.tutorials.map((tutorial) => (
|
||||
<li key={tutorial.id} className="mb-1">
|
||||
<button
|
||||
onClick={() =>
|
||||
vscode.postMessage({
|
||||
type: "start",
|
||||
tutorialId: tutorial.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
<TutorialProgressIndicator
|
||||
currentStep={tutorial.currentStep}
|
||||
stepCount={tutorial.stepCount}
|
||||
/>
|
||||
{tutorial.title}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
);
|
||||
|
||||
case "doingTutorial":
|
||||
return state.hasErrors ? (
|
||||
<div>
|
||||
<h1 className="text-[color:var(--vscode-walkthrough-stepTitle\.foreground)]">
|
||||
Error
|
||||
</h1>
|
||||
<p>
|
||||
{state.requiresTalonUpdate ? (
|
||||
<>
|
||||
Please{" "}
|
||||
<a
|
||||
href="https://www.cursorless.org/docs/user/updating/#updating-the-talon-side"
|
||||
className="text-blue-400"
|
||||
>
|
||||
update cursorless-talon
|
||||
</a>
|
||||
</>
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<TutorialStep state={state} vscode={vscode} />
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const TutorialProgressIndicator: FunctionComponent<{
|
||||
currentStep: number;
|
||||
stepCount: number;
|
||||
}> = ({ currentStep, stepCount }) => {
|
||||
if (currentStep === 0) {
|
||||
return null;
|
||||
}
|
||||
if (currentStep === stepCount - 1) {
|
||||
return <span className="mr-1">✅</span>;
|
||||
}
|
||||
return <span className="mr-1">🕗</span>;
|
||||
};
|
@ -0,0 +1,21 @@
|
||||
import { type FunctionComponent } from "react";
|
||||
|
||||
export const CloseIcon: FunctionComponent = () => {
|
||||
// From https://github.com/microsoft/vscode-codicons/blob/eaa030691d720b9c5c0efa93d9be9e2e45d7262b/src/icons/close.svg
|
||||
// FIXME: Use codicons the way it's intended; see https://github.com/microsoft/vscode-codicons
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.707.708L7.293 8l-3.646 3.646.707.708L8 8.707z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
@ -0,0 +1,9 @@
|
||||
import { type FunctionComponent } from "react";
|
||||
|
||||
interface CommandProps {
|
||||
spokenForm: string;
|
||||
}
|
||||
|
||||
export const Command: FunctionComponent<CommandProps> = ({ spokenForm }) => {
|
||||
return <code>{`"${spokenForm}"`}</code>;
|
||||
};
|
@ -0,0 +1,26 @@
|
||||
import { type FunctionComponent } from "react";
|
||||
|
||||
interface ProgressBarProps {
|
||||
currentStep: number;
|
||||
stepCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A progress bar that shows the current step in a tutorial.
|
||||
*
|
||||
* From https://flowbite.com/docs/components/progress/
|
||||
*/
|
||||
export const ProgressBar: FunctionComponent<ProgressBarProps> = ({
|
||||
currentStep,
|
||||
stepCount,
|
||||
}) => {
|
||||
const progress = ((currentStep + 1) / stepCount) * 100;
|
||||
return (
|
||||
<div className="h-2.5 w-full rounded-full bg-[var(--vscode-welcomePage-progress\.background)]">
|
||||
<div
|
||||
className="h-2.5 rounded-full bg-[var(--vscode-welcomePage-progress\.foreground)]"
|
||||
style={{ width: `${progress}%` }}
|
||||
></div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,66 @@
|
||||
import { ActiveTutorialNoErrorsState } from "@cursorless/common";
|
||||
import { type FunctionComponent } from "react";
|
||||
import { Command } from "./Command";
|
||||
import { WebviewApi } from "vscode-webview";
|
||||
import { CloseIcon } from "./CloseIcon";
|
||||
import { ProgressBar } from "./ProgressBar";
|
||||
|
||||
interface TutorialStepProps {
|
||||
state: ActiveTutorialNoErrorsState;
|
||||
vscode: WebviewApi<undefined>;
|
||||
}
|
||||
|
||||
export const TutorialStep: FunctionComponent<TutorialStepProps> = ({
|
||||
state,
|
||||
vscode,
|
||||
}) => {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2 mt-2 flex items-center gap-[0.2em]">
|
||||
<ProgressBar
|
||||
currentStep={state.stepNumber}
|
||||
stepCount={state.stepCount}
|
||||
/>
|
||||
<button
|
||||
onClick={() =>
|
||||
vscode.postMessage({
|
||||
type: "list",
|
||||
})
|
||||
}
|
||||
>
|
||||
<span>
|
||||
<CloseIcon />
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{state.preConditionsMet ? (
|
||||
state.stepContent.map((paragraph, i) => (
|
||||
<div key={i} className="mt-1">
|
||||
{paragraph.map((fragment, j) => {
|
||||
switch (fragment.type) {
|
||||
case "string":
|
||||
return <span key={j}>{fragment.value}</span>;
|
||||
case "command":
|
||||
return <Command spokenForm={fragment.value} />;
|
||||
case "term":
|
||||
return <span>"{fragment.value}"</span>;
|
||||
default: {
|
||||
// Ensure we handle all cases
|
||||
const _unused: never = fragment;
|
||||
}
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<div>Whoops! Looks like you've stepped off the beaten path.</div>
|
||||
<div className="mt-1">
|
||||
Feel free to keep playing, then say{" "}
|
||||
<Command spokenForm="tutorial resume" /> to resume the tutorial.
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
@ -0,0 +1,3 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
@ -0,0 +1,6 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<App vscode={acquireVsCodeApi()} />,
|
||||
);
|
@ -0,0 +1,13 @@
|
||||
import { readFileSync } from "fs";
|
||||
|
||||
const references = JSON.parse(
|
||||
readFileSync("tsconfig.json", "utf-8"),
|
||||
).references.map((ref) => ref.path);
|
||||
|
||||
export const content = [".", ...references].map(
|
||||
(dir) => `${dir}/src/**/*!(*.stories|*.spec).{ts,tsx,html}`,
|
||||
);
|
||||
export const theme = {
|
||||
extend: {},
|
||||
};
|
||||
export const plugins = [];
|
20
packages/cursorless-vscode-tutorial-webview/tsconfig.json
Normal file
20
packages/cursorless-vscode-tutorial-webview/tsconfig.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "out",
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["es2022", "dom"]
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "../common"
|
||||
}
|
||||
],
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.json",
|
||||
"src/**/*.tsx",
|
||||
"../../typings/**/*.d.ts"
|
||||
]
|
||||
}
|
@ -56,6 +56,11 @@
|
||||
"contributes": {
|
||||
"views": {
|
||||
"cursorless": [
|
||||
{
|
||||
"type": "webview",
|
||||
"id": "cursorless.tutorial",
|
||||
"name": "Tutorial"
|
||||
},
|
||||
{
|
||||
"id": "cursorless.scopes",
|
||||
"name": "Scopes"
|
||||
@ -111,6 +116,36 @@
|
||||
"command": "cursorless.analyzeCommandHistory",
|
||||
"title": "Cursorless: Analyze collected command history"
|
||||
},
|
||||
{
|
||||
"command": "cursorless.tutorial.start",
|
||||
"title": "Cursorless: Start a tutorial",
|
||||
"enablement": "false"
|
||||
},
|
||||
{
|
||||
"command": "cursorless.tutorial.next",
|
||||
"title": "Cursorless: Tutorial next"
|
||||
},
|
||||
{
|
||||
"command": "cursorless.tutorial.previous",
|
||||
"title": "Cursorless: Tutorial previous"
|
||||
},
|
||||
{
|
||||
"command": "cursorless.tutorial.restart",
|
||||
"title": "Cursorless: Tutorial restart"
|
||||
},
|
||||
{
|
||||
"command": "cursorless.tutorial.resume",
|
||||
"title": "Cursorless: Tutorial resume"
|
||||
},
|
||||
{
|
||||
"command": "cursorless.tutorial.list",
|
||||
"title": "Cursorless: Tutorial list"
|
||||
},
|
||||
{
|
||||
"command": "cursorless.docsOpened",
|
||||
"title": "Cursorless: Used by talon to notify us that the docs have been opened; for use with tutorial",
|
||||
"enablement": "false"
|
||||
},
|
||||
{
|
||||
"command": "cursorless.command",
|
||||
"title": "Cursorless: The core cursorless command",
|
||||
@ -1192,8 +1227,8 @@
|
||||
},
|
||||
"funding": "https://github.com/sponsors/pokey",
|
||||
"scripts": {
|
||||
"build": "pnpm run esbuild:prod && pnpm -F cheatsheet-local build:prod && pnpm run populate-dist",
|
||||
"build:dev": "pnpm generate-grammar && pnpm run esbuild && pnpm -F cheatsheet-local build && pnpm run populate-dist",
|
||||
"build": "pnpm run esbuild:prod && pnpm -F cheatsheet-local build:prod && pnpm -F cursorless-vscode-tutorial-webview build:prod && pnpm run populate-dist",
|
||||
"build:dev": "pnpm generate-grammar && pnpm run esbuild && pnpm -F cheatsheet-local build && pnpm -F cursorless-vscode-tutorial-webview build && pnpm run populate-dist",
|
||||
"esbuild:base": "esbuild ./src/extension.ts --conditions=cursorless:bundler --bundle --outfile=dist/extension.cjs --external:vscode --format=cjs --platform=node",
|
||||
"install-local": "bash ./scripts/install-local.sh",
|
||||
"install-from-pr": "bash ./scripts/install-from-pr.sh",
|
||||
|
62
packages/cursorless-vscode/src/SpyWebviewView.ts
Normal file
62
packages/cursorless-vscode/src/SpyWebviewView.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { Disposable } from "@cursorless/common";
|
||||
import { cloneDeep } from "lodash-es";
|
||||
import { Uri, Webview, WebviewView } from "vscode";
|
||||
import { SpyWebViewEvent } from "@cursorless/vscode-common";
|
||||
|
||||
/**
|
||||
* Wraps a {@link WebviewView} and provides a way to spy on its events for
|
||||
* testing.
|
||||
*/
|
||||
export class SpyWebviewView {
|
||||
readonly webview: SpyWebview;
|
||||
private eventLog: SpyWebViewEvent[] = [];
|
||||
|
||||
constructor(public view: WebviewView) {
|
||||
this.webview = new SpyWebview(this.eventLog, view.webview);
|
||||
}
|
||||
|
||||
getEventLog(): SpyWebViewEvent[] {
|
||||
return cloneDeep(this.eventLog);
|
||||
}
|
||||
|
||||
show(preserveFocus: boolean): void {
|
||||
this.view.show(preserveFocus);
|
||||
this.eventLog.push({ type: "viewShown", preserveFocus });
|
||||
}
|
||||
}
|
||||
|
||||
class SpyWebview {
|
||||
constructor(
|
||||
private eventLog: SpyWebViewEvent[],
|
||||
private view: Webview,
|
||||
) {
|
||||
this.view.onDidReceiveMessage((data) => {
|
||||
this.eventLog.push({ type: "messageReceived", data });
|
||||
});
|
||||
}
|
||||
|
||||
set html(value: string) {
|
||||
this.view.html = value;
|
||||
}
|
||||
|
||||
set options(value: { enableScripts: boolean; localResourceRoots: Uri[] }) {
|
||||
this.view.options = value;
|
||||
}
|
||||
|
||||
onDidReceiveMessage(callback: (data: any) => void): Disposable {
|
||||
return this.view.onDidReceiveMessage(callback);
|
||||
}
|
||||
|
||||
postMessage(data: any): Thenable<boolean> {
|
||||
this.eventLog.push({ type: "messageSent", data });
|
||||
return this.view.postMessage(data);
|
||||
}
|
||||
|
||||
asWebviewUri(localResource: Uri): Uri {
|
||||
return this.view.asWebviewUri(localResource);
|
||||
}
|
||||
|
||||
get cspSource(): string {
|
||||
return this.view.cspSource;
|
||||
}
|
||||
}
|
226
packages/cursorless-vscode/src/VscodeTutorial.ts
Normal file
226
packages/cursorless-vscode/src/VscodeTutorial.ts
Normal file
@ -0,0 +1,226 @@
|
||||
import { FileSystem, TutorialId, TutorialState } from "@cursorless/common";
|
||||
import { Tutorial } from "@cursorless/cursorless-engine";
|
||||
import { getCursorlessRepoRoot } from "@cursorless/node-common";
|
||||
import { SpyWebViewEvent, VscodeApi } from "@cursorless/vscode-common";
|
||||
import path from "node:path";
|
||||
import {
|
||||
CancellationToken,
|
||||
ExtensionContext,
|
||||
ExtensionMode,
|
||||
Uri,
|
||||
WebviewView,
|
||||
WebviewViewProvider,
|
||||
WebviewViewResolveContext,
|
||||
} from "vscode";
|
||||
import { ScopeVisualizer } from "./ScopeVisualizerCommandApi";
|
||||
import { SpyWebviewView } from "./SpyWebviewView";
|
||||
|
||||
const VSCODE_TUTORIAL_WEBVIEW_ID = "cursorless.tutorial";
|
||||
|
||||
export class VscodeTutorial implements WebviewViewProvider {
|
||||
private view?: WebviewView | SpyWebviewView;
|
||||
private localResourceRoot: Uri;
|
||||
|
||||
constructor(
|
||||
private context: ExtensionContext,
|
||||
private vscodeApi: VscodeApi,
|
||||
private tutorial: Tutorial,
|
||||
scopeVisualizer: ScopeVisualizer,
|
||||
fileSystem: FileSystem,
|
||||
) {
|
||||
this.onState = this.onState.bind(this);
|
||||
this.start = this.start.bind(this);
|
||||
this.docsOpened = this.docsOpened.bind(this);
|
||||
this.next = this.next.bind(this);
|
||||
this.previous = this.previous.bind(this);
|
||||
this.restart = this.restart.bind(this);
|
||||
this.resume = this.resume.bind(this);
|
||||
this.list = this.list.bind(this);
|
||||
|
||||
this.localResourceRoot =
|
||||
context.extensionMode === ExtensionMode.Development
|
||||
? Uri.file(
|
||||
path.join(
|
||||
getCursorlessRepoRoot(),
|
||||
"packages",
|
||||
"cursorless-vscode-tutorial-webview",
|
||||
"out",
|
||||
),
|
||||
)
|
||||
: Uri.joinPath(context.extensionUri, "media");
|
||||
|
||||
context.subscriptions.push(
|
||||
vscodeApi.window.registerWebviewViewProvider(
|
||||
VSCODE_TUTORIAL_WEBVIEW_ID,
|
||||
this,
|
||||
),
|
||||
tutorial.onState(this.onState),
|
||||
scopeVisualizer.onDidChangeScopeType((scopeType) => {
|
||||
this.tutorial.scopeTypeVisualized(scopeType);
|
||||
}),
|
||||
);
|
||||
|
||||
if (context.extensionMode === ExtensionMode.Development) {
|
||||
context.subscriptions.push(
|
||||
fileSystem.watchDir(this.localResourceRoot.fsPath, () => {
|
||||
if (this.view != null) {
|
||||
this.view.webview.html = this.getHtmlForWebview();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public resolveWebviewView(
|
||||
webviewView: WebviewView,
|
||||
_context: WebviewViewResolveContext,
|
||||
_token: CancellationToken,
|
||||
) {
|
||||
if (this.view != null && this.view instanceof SpyWebviewView) {
|
||||
this.view.view = webviewView;
|
||||
} else {
|
||||
this.view =
|
||||
this.context.extensionMode === ExtensionMode.Test
|
||||
? new SpyWebviewView(webviewView)
|
||||
: webviewView;
|
||||
}
|
||||
const { webview } = this.view;
|
||||
|
||||
webview.options = {
|
||||
enableScripts: true,
|
||||
localResourceRoots: [this.localResourceRoot],
|
||||
};
|
||||
|
||||
webview.html = this.getHtmlForWebview();
|
||||
|
||||
webview.onDidReceiveMessage((data) => {
|
||||
switch (data.type) {
|
||||
case "getInitialState":
|
||||
webview.postMessage(this.tutorial.state);
|
||||
break;
|
||||
case "start":
|
||||
this.start(data.tutorialId);
|
||||
break;
|
||||
case "list":
|
||||
this.list();
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public getEventLog(): SpyWebViewEvent[] {
|
||||
if (this.view instanceof SpyWebviewView) {
|
||||
return this.view.getEventLog();
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
public async start(id: TutorialId | number) {
|
||||
await this.tutorial.start(id);
|
||||
this.revealTutorial();
|
||||
}
|
||||
|
||||
docsOpened() {
|
||||
this.tutorial.docsOpened();
|
||||
this.revealTutorial();
|
||||
}
|
||||
|
||||
async next() {
|
||||
await this.tutorial.next();
|
||||
this.revealTutorial();
|
||||
}
|
||||
|
||||
async previous() {
|
||||
await this.tutorial.previous();
|
||||
this.revealTutorial();
|
||||
}
|
||||
|
||||
async restart() {
|
||||
await this.tutorial.restart();
|
||||
this.revealTutorial();
|
||||
}
|
||||
|
||||
async resume() {
|
||||
await this.tutorial.resume();
|
||||
this.revealTutorial();
|
||||
}
|
||||
|
||||
async list() {
|
||||
await this.tutorial.list();
|
||||
this.revealTutorial();
|
||||
}
|
||||
|
||||
private onState(state: TutorialState) {
|
||||
this.view?.webview.postMessage(state);
|
||||
}
|
||||
|
||||
private revealTutorial() {
|
||||
if (this.view != null) {
|
||||
this.view.show(true);
|
||||
} else {
|
||||
this.vscodeApi.commands.executeCommand("cursorless.tutorial.focus");
|
||||
}
|
||||
}
|
||||
|
||||
private getHtmlForWebview() {
|
||||
const { webview } = this.view!;
|
||||
|
||||
// Get the local path to main script run in the webview, then convert it to a uri we can use in the webview.
|
||||
const scriptUri = webview.asWebviewUri(
|
||||
Uri.joinPath(
|
||||
this.localResourceRoot,
|
||||
this.context.extensionMode === ExtensionMode.Development
|
||||
? "index.js"
|
||||
: "tutorialWebview.js",
|
||||
),
|
||||
);
|
||||
|
||||
// Do the same for the stylesheet.
|
||||
const styleMainUri = webview.asWebviewUri(
|
||||
Uri.joinPath(
|
||||
this.localResourceRoot,
|
||||
this.context.extensionMode === ExtensionMode.Development
|
||||
? "index.css"
|
||||
: "tutorialWebview.css",
|
||||
),
|
||||
);
|
||||
|
||||
// Use a nonce to only allow a specific script to be run.
|
||||
const nonce = getNonce();
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
|
||||
<!--
|
||||
Use a content security policy to only allow loading styles from our extension directory,
|
||||
and only allow scripts that have a specific nonce.
|
||||
-->
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src ${webview.cspSource}; script-src 'nonce-${nonce}';">
|
||||
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
|
||||
<link href="${styleMainUri}" rel="stylesheet">
|
||||
|
||||
<title>Cursorless tutorial</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<script nonce="${nonce}" src="${scriptUri}"></script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
}
|
||||
|
||||
function getNonce() {
|
||||
let text = "";
|
||||
const possible =
|
||||
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
for (let i = 0; i < 32; i++) {
|
||||
text += possible.charAt(Math.floor(Math.random() * possible.length));
|
||||
}
|
||||
return text;
|
||||
}
|
@ -23,6 +23,7 @@ import { VscodeFileSystem } from "./ide/vscode/VscodeFileSystem";
|
||||
import { VscodeIDE } from "./ide/vscode/VscodeIDE";
|
||||
import { toVscodeEditor } from "./ide/vscode/toVscodeEditor";
|
||||
import { vscodeApi } from "./vscodeApi";
|
||||
import { VscodeTutorial } from "./VscodeTutorial";
|
||||
|
||||
export function constructTestHelpers(
|
||||
commandServerApi: FakeCommandServerApi,
|
||||
@ -34,6 +35,7 @@ export function constructTestHelpers(
|
||||
scopeProvider: ScopeProvider,
|
||||
injectIde: (ide: IDE) => void,
|
||||
runIntegrationTests: () => Promise<void>,
|
||||
vscodeTutorial: VscodeTutorial,
|
||||
): VscodeTestHelpers | undefined {
|
||||
return {
|
||||
commandServerApi: commandServerApi!,
|
||||
@ -83,5 +85,8 @@ export function constructTestHelpers(
|
||||
hatTokenMap,
|
||||
runIntegrationTests,
|
||||
vscodeApi,
|
||||
getTutorialWebviewEventLog() {
|
||||
return vscodeTutorial.getEventLog();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@ -1,5 +1,6 @@
|
||||
import {
|
||||
Disposable,
|
||||
EnforceUndefined,
|
||||
FakeCommandServerApi,
|
||||
FakeIDE,
|
||||
IDE,
|
||||
@ -12,12 +13,14 @@ import {
|
||||
} from "@cursorless/common";
|
||||
import {
|
||||
CommandHistory,
|
||||
EngineProps,
|
||||
createCursorlessEngine,
|
||||
} from "@cursorless/cursorless-engine";
|
||||
import {
|
||||
FileSystemCommandHistoryStorage,
|
||||
FileSystemRawTreeSitterQueryProvider,
|
||||
FileSystemTalonSpokenForms,
|
||||
FileSystemTutorialContentProvider,
|
||||
getFixturePath,
|
||||
} from "@cursorless/node-common";
|
||||
import {
|
||||
@ -59,6 +62,7 @@ import { registerCommands } from "./registerCommands";
|
||||
import { revisualizeOnCustomRegexChange } from "./revisualizeOnCustomRegexChange";
|
||||
import { storedTargetHighlighter } from "./storedTargetHighlighter";
|
||||
import { vscodeApi } from "./vscodeApi";
|
||||
import { VscodeTutorial } from "./VscodeTutorial";
|
||||
|
||||
/**
|
||||
* Extension entrypoint called by VSCode on Cursorless startup.
|
||||
@ -103,6 +107,19 @@ export async function activate(
|
||||
);
|
||||
context.subscriptions.push(treeSitterQueryProvider);
|
||||
|
||||
const engineProps: EnforceUndefined<EngineProps> = {
|
||||
ide: normalizedIde,
|
||||
hats,
|
||||
treeSitterQueryProvider,
|
||||
treeSitter,
|
||||
commandServerApi,
|
||||
talonSpokenForms,
|
||||
tutorialContentProvider: new FileSystemTutorialContentProvider(
|
||||
normalizedIde.assetsRoot,
|
||||
),
|
||||
snippets,
|
||||
};
|
||||
|
||||
const {
|
||||
commandApi,
|
||||
storedTargets,
|
||||
@ -112,15 +129,8 @@ export async function activate(
|
||||
runIntegrationTests,
|
||||
addCommandRunnerDecorator,
|
||||
customSpokenFormGenerator,
|
||||
} = await createCursorlessEngine({
|
||||
ide: normalizedIde,
|
||||
hats,
|
||||
treeSitterQueryProvider,
|
||||
treeSitter,
|
||||
commandServerApi,
|
||||
talonSpokenForms,
|
||||
snippets,
|
||||
});
|
||||
tutorial,
|
||||
} = await createCursorlessEngine(engineProps);
|
||||
|
||||
const commandHistoryStorage = new FileSystemCommandHistoryStorage(
|
||||
fileSystem.cursorlessCommandHistoryDirPath,
|
||||
@ -161,6 +171,14 @@ export async function activate(
|
||||
|
||||
context.subscriptions.push(storedTargetHighlighter(vscodeIDE, storedTargets));
|
||||
|
||||
const vscodeTutorial = new VscodeTutorial(
|
||||
context,
|
||||
vscodeApi,
|
||||
tutorial,
|
||||
scopeVisualizer,
|
||||
fileSystem,
|
||||
);
|
||||
|
||||
registerCommands(
|
||||
context,
|
||||
vscodeIDE,
|
||||
@ -171,6 +189,7 @@ export async function activate(
|
||||
scopeVisualizer,
|
||||
keyboardCommands,
|
||||
hats,
|
||||
vscodeTutorial,
|
||||
storedTargets,
|
||||
);
|
||||
|
||||
@ -189,6 +208,7 @@ export async function activate(
|
||||
scopeProvider,
|
||||
injectIde,
|
||||
runIntegrationTests,
|
||||
vscodeTutorial,
|
||||
)
|
||||
: undefined,
|
||||
|
||||
|
@ -18,6 +18,7 @@ import type {
|
||||
} from "@cursorless/test-case-recorder";
|
||||
import * as vscode from "vscode";
|
||||
import { ScopeVisualizer } from "./ScopeVisualizerCommandApi";
|
||||
import { VscodeTutorial } from "./VscodeTutorial";
|
||||
import { showDocumentation, showQuickPick } from "./commands";
|
||||
import { VscodeIDE } from "./ide/vscode/VscodeIDE";
|
||||
import { VscodeHats } from "./ide/vscode/hats/VscodeHats";
|
||||
@ -34,6 +35,7 @@ export function registerCommands(
|
||||
scopeVisualizer: ScopeVisualizer,
|
||||
keyboardCommands: KeyboardCommands,
|
||||
hats: VscodeHats,
|
||||
tutorial: VscodeTutorial,
|
||||
storedTargets: StoredTargetMap,
|
||||
): void {
|
||||
const runCommandWrapper = async (run: () => Promise<unknown>) => {
|
||||
@ -119,9 +121,17 @@ export function registerCommands(
|
||||
["cursorless.keyboard.modal.modeOn"]: keyboardCommands.modal.modeOn,
|
||||
["cursorless.keyboard.modal.modeOff"]: keyboardCommands.modal.modeOff,
|
||||
["cursorless.keyboard.modal.modeToggle"]: keyboardCommands.modal.modeToggle,
|
||||
|
||||
["cursorless.keyboard.undoTarget"]: () => storedTargets.undo("keyboard"),
|
||||
["cursorless.keyboard.redoTarget"]: () => storedTargets.redo("keyboard"),
|
||||
|
||||
// Tutorial commands
|
||||
["cursorless.tutorial.start"]: tutorial.start,
|
||||
["cursorless.tutorial.next"]: tutorial.next,
|
||||
["cursorless.tutorial.previous"]: tutorial.previous,
|
||||
["cursorless.tutorial.restart"]: tutorial.restart,
|
||||
["cursorless.tutorial.resume"]: tutorial.resume,
|
||||
["cursorless.tutorial.list"]: tutorial.list,
|
||||
["cursorless.docsOpened"]: tutorial.docsOpened,
|
||||
};
|
||||
|
||||
extensionContext.subscriptions.push(
|
||||
|
@ -24,6 +24,18 @@ export const assets: Asset[] = [
|
||||
destination: "fonts/cursorless.woff",
|
||||
},
|
||||
{ source: "../../images/hats", destination: "images/hats" },
|
||||
{
|
||||
source: "../../data/fixtures/recorded/tutorial",
|
||||
destination: "tutorial",
|
||||
},
|
||||
{
|
||||
source: "../cursorless-vscode-tutorial-webview/out/index.js",
|
||||
destination: "media/tutorialWebview.js",
|
||||
},
|
||||
{
|
||||
source: "../cursorless-vscode-tutorial-webview/out/index.css",
|
||||
destination: "media/tutorialWebview.css",
|
||||
},
|
||||
{ source: "./images/logo.png", destination: "images/logo.png" },
|
||||
{ source: "../../images/logo.svg", destination: "images/logo.svg" },
|
||||
{
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { VscodeApi } from "@cursorless/vscode-common";
|
||||
import { env, window, workspace } from "vscode";
|
||||
import { commands, env, window, workspace } from "vscode";
|
||||
|
||||
/**
|
||||
* A very thin wrapper around the VSCode API that allows us to mock it for
|
||||
@ -12,6 +12,7 @@ export const vscodeApi: VscodeApi = {
|
||||
workspace,
|
||||
window,
|
||||
env,
|
||||
commands,
|
||||
editor: {
|
||||
setDecorations(editor, ...args) {
|
||||
return editor.setDecorations(...args);
|
||||
|
@ -0,0 +1,49 @@
|
||||
import {
|
||||
RawTutorialContent,
|
||||
TutorialContentProvider,
|
||||
TutorialId,
|
||||
} from "@cursorless/common";
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import path from "path";
|
||||
import { loadFixture } from "./loadFixture";
|
||||
|
||||
export class FileSystemTutorialContentProvider
|
||||
implements TutorialContentProvider
|
||||
{
|
||||
private tutorialRootDir: string;
|
||||
|
||||
constructor(assetsRoot: string) {
|
||||
this.tutorialRootDir = path.join(assetsRoot, "tutorial");
|
||||
}
|
||||
|
||||
async loadRawTutorials() {
|
||||
const tutorialDirs = await readdir(this.tutorialRootDir, {
|
||||
withFileTypes: true,
|
||||
});
|
||||
|
||||
return await Promise.all(
|
||||
tutorialDirs
|
||||
.filter((dirent) => dirent.isDirectory())
|
||||
.map((dirent) => this.loadTutorialScript(dirent.name as TutorialId)),
|
||||
);
|
||||
}
|
||||
|
||||
private async loadTutorialScript(
|
||||
tutorialId: string,
|
||||
): Promise<RawTutorialContent> {
|
||||
const buffer = await readFile(
|
||||
path.join(this.tutorialRootDir, tutorialId, "script.json"),
|
||||
);
|
||||
|
||||
return {
|
||||
id: tutorialId,
|
||||
...JSON.parse(buffer.toString()),
|
||||
};
|
||||
}
|
||||
|
||||
async loadFixture(tutorialId: TutorialId, fixtureName: string) {
|
||||
return loadFixture(
|
||||
path.join(this.tutorialRootDir, tutorialId, fixtureName),
|
||||
);
|
||||
}
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
export * from "./FileSystemCommandHistoryStorage";
|
||||
export * from "./FileSystemRawTreeSitterQueryProvider";
|
||||
export * from "./FileSystemTalonSpokenForms";
|
||||
export * from "./FileSystemTutorialContentProvider";
|
||||
export * from "./getCursorlessRepoRoot";
|
||||
export * from "./getFixturePaths";
|
||||
export * from "./getScopeTestPathsRecursively";
|
||||
@ -8,3 +9,4 @@ export * from "./nodeGetRunMode";
|
||||
export * from "./runRecordedTest";
|
||||
export * from "./walkAsync";
|
||||
export * from "./walkSync";
|
||||
export * from "./loadFixture";
|
||||
|
17
packages/vscode-common/src/SpyWebViewEvent.ts
Normal file
17
packages/vscode-common/src/SpyWebViewEvent.ts
Normal file
@ -0,0 +1,17 @@
|
||||
interface MessageSentEvent {
|
||||
type: "messageSent";
|
||||
data: any;
|
||||
}
|
||||
interface MessageReceivedEvent {
|
||||
type: "messageReceived";
|
||||
data: any;
|
||||
}
|
||||
interface ViewShownEvent {
|
||||
type: "viewShown";
|
||||
preserveFocus: boolean;
|
||||
}
|
||||
|
||||
export type SpyWebViewEvent =
|
||||
| MessageSentEvent
|
||||
| MessageReceivedEvent
|
||||
| ViewShownEvent;
|
@ -7,6 +7,7 @@ import type {
|
||||
} from "@cursorless/common";
|
||||
import * as vscode from "vscode";
|
||||
import { VscodeApi } from "./VscodeApi";
|
||||
import { SpyWebViewEvent } from "./SpyWebViewEvent";
|
||||
|
||||
export interface VscodeTestHelpers extends TestHelpers {
|
||||
ide: NormalizedIDE;
|
||||
@ -22,6 +23,17 @@ export interface VscodeTestHelpers extends TestHelpers {
|
||||
cursorlessTalonStateJsonPath: string;
|
||||
cursorlessCommandHistoryDirPath: string;
|
||||
|
||||
/**
|
||||
* Returns the event log for the VSCode tutorial component. Used to test that
|
||||
* the VSCode side of the tutorial is sending messages to the webview, and
|
||||
* that the webview is sending messages back to the VSCode side. Note that
|
||||
* this log is maintained by the VSCode side, not the webview side, so
|
||||
* `messageSent` means that the VSCode side sent a message to the webview, and
|
||||
* `messageReceived` means that the VSCode side received a message from the
|
||||
* webview.
|
||||
*/
|
||||
getTutorialWebviewEventLog(): SpyWebViewEvent[];
|
||||
|
||||
/**
|
||||
* A thin wrapper around the VSCode API that allows us to mock it for testing.
|
||||
*/
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { workspace, window, TextEditor, env } from "vscode";
|
||||
import { workspace, window, TextEditor, env, commands } from "vscode";
|
||||
|
||||
/**
|
||||
* Subset of VSCode api that we need to be able to mock for testing
|
||||
@ -7,6 +7,7 @@ export interface VscodeApi {
|
||||
workspace: typeof workspace;
|
||||
window: typeof window;
|
||||
env: typeof env;
|
||||
commands: typeof commands;
|
||||
|
||||
/**
|
||||
* Wrapper around editor api for easy mocking. Provides various
|
||||
|
@ -6,3 +6,4 @@ export * from "./vscodeUtil";
|
||||
export * from "./runCommand";
|
||||
export * from "./VscodeApi";
|
||||
export * from "./ScopeVisualizerColorConfig";
|
||||
export * from "./SpyWebViewEvent";
|
||||
|
@ -590,6 +590,31 @@ importers:
|
||||
specifier: ^17.0.1
|
||||
version: 17.0.1
|
||||
|
||||
packages/cursorless-vscode-tutorial-webview:
|
||||
dependencies:
|
||||
'@cursorless/common':
|
||||
specifier: workspace:*
|
||||
version: link:../common
|
||||
react:
|
||||
specifier: ^18.2.0
|
||||
version: 18.2.0
|
||||
react-dom:
|
||||
specifier: ^18.2.0
|
||||
version: 18.2.0(react@18.2.0)
|
||||
devDependencies:
|
||||
'@types/react':
|
||||
specifier: 18.2.71
|
||||
version: 18.2.71
|
||||
'@types/react-dom':
|
||||
specifier: 18.2.22
|
||||
version: 18.2.22
|
||||
'@types/vscode-webview':
|
||||
specifier: 1.57.5
|
||||
version: 1.57.5
|
||||
tailwindcss:
|
||||
specifier: 3.4.1
|
||||
version: 3.4.1(ts-node@10.9.2(@types/node@18.18.2)(typescript@5.4.3))
|
||||
|
||||
packages/meta-updater:
|
||||
dependencies:
|
||||
'@cursorless/common':
|
||||
@ -2973,6 +2998,9 @@ packages:
|
||||
'@types/vinyl@2.0.11':
|
||||
resolution: {integrity: sha512-vPXzCLmRp74e9LsP8oltnWKTH+jBwt86WgRUb4Pc9Lf3pkMVGyvIo2gm9bODeGfCay2DBB/hAWDuvf07JcK4rw==}
|
||||
|
||||
'@types/vscode-webview@1.57.5':
|
||||
resolution: {integrity: sha512-iBAUYNYkz+uk1kdsq05fEcoh8gJmwT3lqqFPN7MGyjQ3HVloViMdo7ZJ8DFIP8WOK74PjOEilosqAyxV2iUFUw==}
|
||||
|
||||
'@types/vscode@1.75.1':
|
||||
resolution: {integrity: sha512-emg7wdsTFzdi+elvoyoA+Q8keEautdQHyY5LNmHVM4PTpY8JgOTVADrGVyXGepJ6dVW2OS5/xnLUWh+nZxvdiA==}
|
||||
|
||||
@ -12853,6 +12881,8 @@ snapshots:
|
||||
'@types/expect': 1.20.4
|
||||
'@types/node': 18.18.2
|
||||
|
||||
'@types/vscode-webview@1.57.5': {}
|
||||
|
||||
'@types/vscode@1.75.1': {}
|
||||
|
||||
'@types/webpack@5.28.5(esbuild@0.20.2)(webpack-cli@5.1.4(@webpack-cli/generators@3.0.7)(webpack-dev-server@5.0.4)(webpack@5.91.0))':
|
||||
|
@ -29,6 +29,9 @@
|
||||
{
|
||||
"path": "./packages/cursorless-vscode-e2e"
|
||||
},
|
||||
{
|
||||
"path": "./packages/cursorless-vscode-tutorial-webview"
|
||||
},
|
||||
{
|
||||
"path": "./packages/meta-updater"
|
||||
},
|
||||
|
Loading…
Reference in New Issue
Block a user