Cursorless tutorial

This commit is contained in:
Pokey Rule 2024-07-16 19:23:52 +01:00
parent a2e4a61858
commit be21ac43e8
85 changed files with 3577 additions and 34 deletions

22
.vscode/tasks.json vendored
View File

@ -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",

View File

@ -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(),
},
]
}

View 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",
},
],
},
]

View File

@ -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")

View File

@ -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)

View 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}

View File

@ -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}

View 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}

View 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}

View 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}

View 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."
]
}

View File

@ -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}

View 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}

View File

@ -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}

View 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}

View File

@ -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}

View File

@ -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}

View File

@ -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}

View File

@ -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}

View File

@ -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}

View File

@ -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}

View File

@ -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}

View File

@ -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."
]
}

View File

@ -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}

View 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",
}

View File

@ -0,0 +1,11 @@
==================================================
========== ==========
========== Welcome to Cursorless! ==========
========== ==========
========== Let's start using marks ==========
========== ==========
========== so we can navigate around ==========
========== ==========
========== without lifting a finger! ==========
========== ==========
==================================================

View 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")

View File

@ -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"
}
]
}
]
}
]
}

View File

@ -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",

View File

@ -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

View 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[];
}

View File

@ -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";

View 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;

View File

@ -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: (

View 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;
}

View File

@ -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,
};
}

View File

@ -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
}
}

View File

@ -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();
}

View File

@ -1,4 +1,5 @@
export * from "./testUtil/plainObjectToTarget";
export * from "./api/Tutorial";
export * from "./core/StoredTargets";
export * from "./cursorlessEngine";
export * from "./generateSpokenForm/defaultSpokenForms/surroundingPairsDelimiters";

View 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;
}
}

View 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,
});
}
}
}
}

View 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];
}
}

View File

@ -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;
}

View File

@ -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];
}

View 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 };
}

View 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;
}
}
}

View 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",
};

View File

@ -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;
}
}

View File

@ -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];
}
}

View File

@ -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];
}
}

View File

@ -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,
};
}

View File

@ -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,
},
};
}

View File

@ -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;
},
};
}

View File

@ -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>;
}

View File

@ -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;

View File

@ -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;
}

View File

@ -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;

View File

@ -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,
});

View 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
```

View 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"
}
}

View 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>;
};

View File

@ -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>
);
};

View File

@ -0,0 +1,9 @@
import { type FunctionComponent } from "react";
interface CommandProps {
spokenForm: string;
}
export const Command: FunctionComponent<CommandProps> = ({ spokenForm }) => {
return <code>{`"${spokenForm}"`}</code>;
};

View File

@ -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>
);
};

View File

@ -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>
);
};

View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -0,0 +1,6 @@
import { createRoot } from "react-dom/client";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(
<App vscode={acquireVsCodeApi()} />,
);

View File

@ -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 = [];

View 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"
]
}

View File

@ -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",

View 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;
}
}

View 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;
}

View File

@ -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();
},
};
}

View File

@ -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,

View File

@ -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(

View File

@ -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" },
{

View File

@ -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);

View File

@ -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),
);
}
}

View File

@ -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";

View 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;

View File

@ -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.
*/

View File

@ -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

View File

@ -6,3 +6,4 @@ export * from "./vscodeUtil";
export * from "./runCommand";
export * from "./VscodeApi";
export * from "./ScopeVisualizerColorConfig";
export * from "./SpyWebViewEvent";

View File

@ -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))':

View File

@ -29,6 +29,9 @@
{
"path": "./packages/cursorless-vscode-e2e"
},
{
"path": "./packages/cursorless-vscode-tutorial-webview"
},
{
"path": "./packages/meta-updater"
},