Editing

Multiple cursors

Every cursor edits at once; typing, deleting, moving and pasting apply to all of them.

  • Alt+click adds a cursor; Alt+double-click adds the word under the pointer as a region; Alt+drag extends the last added region.
  • Shift+Alt+drag makes a column selection: one region per line between the two corners of the box, clamped to shorter lines (a line that ends before the box's left edge is skipped, as in VS Code). Drag up or left and the regions run backwards, so typing goes where the pointer is. A zero-width box gives a caret on every line.
  • Select next occurrence (⌘D / Ctrl+D): a caret first becomes its word; each press adds the next occurrence of the last added region, wrapping around the document and never selecting the same text twice.
  • Select all occurrences (⌘⇧L / Ctrl+Shift+L): every occurrence of the first region's text (the word under a caret).
  • Skip occurrence (⌘K ⌘D / Ctrl+K Ctrl+D): moves the last added region to the next occurrence instead of adding it.
  • Undo selection takes back the last change of selection; Insert cursor above / below and cursor at end of each selected line are in the palette and the keymap.

editor.multicursor-case-sensitive makes the occurrence commands match case regardless of the find bar's setting. The behaviour above is pinned by the test suite in graphoos-app/src/editor/multicursor.rs.

User snippets

Your own snippets live in TOML files, one per language: Open User Snippets in the palette opens (and creates, from a template) the file of the active document's language under the config directory (snippets/<language>.toml); a workspace adds its own in .graphoos/snippets/<language>.toml, and global.toml in either place applies to every language. Later files win on the same prefix.

[[snippets]]
prefix = "tfn"
description = "test function"
body = """
#[test]
fn ${1:name}() {
    ${2:assert!(true)};$0
}
"""

Type the prefix and pick the snippet in the completion list (it shows with its description); Tab jumps between placeholders, $0 is where the cursor ends. The body is the LSP snippet syntax the editor already speaks: $1, ${1:default}, nested ${1:outer ${2:inner}}, choices ${1|a,b,c|} (the first is inserted). body can also be a list of lines.

Variables are expanded when the snippet is inserted: $TM_FILENAME, $TM_FILENAME_BASE, $TM_DIRECTORY, $TM_FILEPATH, $TM_LINE_INDEX, $TM_LINE_NUMBER, $TM_SELECTED_TEXT, $CLIPBOARD, $WORKSPACE_NAME, $CURRENT_YEAR, $CURRENT_YEAR_SHORT, $CURRENT_MONTH, $CURRENT_DATE, $CURRENT_HOUR, $CURRENT_MINUTE, $CURRENT_SECOND, $RANDOM, $RANDOM_HEX, $UUID; ${VAR:default} when the variable is empty. Transformations follow VS Code: ${TM_FILENAME/(.*)\..+$/$1/} strips the extension, ${TM_FILENAME_BASE/(.*)/\U$1/} upper-cases, \u \l \L change case, g and i are the flags. A transformation on a placeholder (${1/…/…/}) is applied once, to its default text, when the snippet is inserted.

The bar over the editor shows the file's path and, after it, the chain of symbols the cursor is inside (src › doc.rs › impl Doc › save), from the language server. editor.show-bread-crumbs = false hides the bar.

editor.rulers = [80, 100] draws a vertical guide at those columns.

Refactors across files

Rename Symbol (F2) asks the language server for every place the symbol is used. When the change touches more than the file you are in, GraphoOS says how much it would change (12 changes in 5 files) and offers three answers: Apply, See the changes — which opens a Refactor preview.md tab with each file and its lines before and after — or Cancel. A rename inside the file you are already looking at is applied straight away.

Undo Last Refactor puts the last applied refactor back in every file it touched, in one step (each file's own undo, so it can be redone).

Organize Imports and Apply All Fixes ask the language server for its source.organizeImports / source.fixAll action and run it through the same preview; when the server offers nothing there, it says so rather than doing nothing silently.

Merge conflicts

A file a merge left with <<<<<<< / ======= / >>>>>>> blocks is resolved from the palette: Merge Conflict: Keep Current Change, Keep Incoming Change, Keep Both Changes, and, when git wrote the common ancestor too (merge.conflictStyle = diff3), Keep the Common Ancestor. They act on the conflict the cursor is inside and say how many are left in the file; Go to the Next One jumps to the next block, wrapping. Each resolution is a normal edit, so undo puts the block back.

Each side is painted in its own colour — this branch's in green, the incoming one in blue, the ancestor's in red — with its marker line a shade stronger, and the first line of every conflict has an icon in the margin. The icon, and a right-click anywhere inside a conflict, offer the same actions, plus:

  • Compare the Two Sides: the two sides of that conflict in a diff editor, as files named after the branches (main (HEAD).rs, main (feature).rs).
  • Show What Each Side Changed: the three versions git keeps while a merge is unfinished — the common ancestor, this branch's and the incoming one, whole — opened as two diffs against the ancestor. The result is made in the file itself, with the actions above.

What the file tree tells you

Besides colouring changed files, each row shows at its right what is going on with it: the git status letter (M modified, A added, D deleted, R renamed) and the problems the language server found (2 ⨯ · 1 ⚠). A folder carries the sum of what is under it, so a collapsed tree still points at where the errors are.

Git: staging, branches, merges

The Git menu (and the palette) runs git itself, so each operation means what it means on the command line, hooks and configuration included; what it did, or what git answered, comes back as a message.

  • One change at a time: right-click a changed line — Stage This Change, Unstage This Change, Discard This Change — or Stage This File / Unstage This File for the whole file. Files with something staged carry an S in the Source Control panel, and the panel says how many there are.
  • Commit Staged commits what is staged, exactly as staged, with the message in the Source Control box (the Commit button still commits the ticked files whole). Amend the Last Commit redoes the last one with what is staged, and with the box's message when there is one.
  • Fetch, and Sync: pull — merging when both sides moved — then push; a branch that follows nothing yet goes to the first remote and follows it from then on.
  • Merge a Branch…, Rebase onto…, Cherry-Pick a Commit… pick what they need in the palette. When one stops on conflicts, the panel says it is in progress, the files in conflict are among the changes, and Continue (once they are resolved and staged) or Abort finish it.
  • Rename This Branch…, Delete a Branch… (one with commits of its own is kept unless git is told to force it), Create a Tag… (a name, then a message for an annotated tag), Delete a Tag…, Push Tags, Add a Remote… (a name and a URL).

Putting changes aside

Git: Put the Changes Aside (Stash) takes everything the working tree has — including files git has never seen, which is how a stash loses a new file — and leaves the tree as it was committed. Git: What Is Put Aside lists what is there, newest first, with when it was made; picking one brings it back and takes it off the stack. Git: Bring Back the Last Thing Put Aside does the same without asking which.

Each of them says what it did. A command that empties your changes without a word is indistinguishable from one that lost them.

Git history of a file

Git: History of This File in the palette lists the commits that touched the active file, newest first, each with its short id, author and date; picking one opens that version as a diff against the file as it is now. It reads the repository directly (no git process), and a file that did not exist in that commit says so.

Local history

Every time a file is saved — and the first time it is opened — GraphoOS keeps a copy of it outside the workspace (history/ in the data directory). It works for files that are not in git, and for changes made between commits.

Local History: Open a Saved Version in the palette lists the versions of the active file, newest first, each with its time, how long ago it was, how many lines it differs from what is on disk now, and its size; picking one opens it as a diff against the current file. Local History: Restore a Saved Version writes the chosen version back over the file (the current content is saved as a version first, so the restore itself can be undone), and Local History: Forget This File's Versions deletes them.

The newest 50 versions of each file are kept, and nothing older than 30 days; editor.local-history = false turns the whole thing off.

The changes as a patch

Git: Show the Changes of This File as a Patch opens what changed against the version in git as a .diff tab: the git diff form, with its @@ hunks and three lines of context, which can be read whole, copied into a message or applied somewhere else. Side by side is better for reading a change in place; a patch is better for seeing the whole of it at once.

Diff: Show This Comparison as a Patch does the same for whatever comparison is open — against a branch, against a commit, or the two files of a folder diff — because it only needs the two sides, which the comparison already holds.

Git: Compare This File With… opens the file beside the version in any branch, tag or commit of the repository, which is how a change is read against something other than the last commit.

The file explorer

Each row carries what the file is: the git status letter (M, A, D, R) and the number of errors and warnings in it, and a folder carries the sum of what is under it, so the tree says where to look without opening anything.

Dragging a file or a folder onto another folder moves it there; dropping it on a file puts it beside that file; and dropping a file on an editor group opens it there. The folder it would land in is marked while you drag, and a move that would lose the file — into itself, over something of the same name — is refused with the reason. Everything open follows the file to its new path.

Comparing two folders: Select for Compare on one folder and Compare Folder with Selected on another lists the files that differ — those only on one side and those whose content changed — and picking one opens the two as a diff. What is the same is not listed, and .git, node_modules and target are not walked into.

The right-click menu has New File, New Directory, Open, Open to the Side, Reveal in Finder (or the file manager of the system), Open in Integrated Terminal, Find in Folder…, Open Changes, Open on Remote (web), History of This File, Rename, Duplicate, Move File/Directory to Trash, Copy Path, Copy Relative Path, Select for Compare / Compare with Selected and Refresh.

Running the tests of what you clicked: Run the Tests, Debug the Tests and Run the Tests with Coverage appear on a file or a folder when the workspace says how — cargo test <name> (--lib for src, --tests for tests), pytest <path>, go test ./dir/... or npm test -- <path> —, and the run goes to the Run and Debug panel like any other task. Coverage asks each tool in its own way (pytest --cov, go test -cover, npm test -- --coverage, cargo llvm-cov), so it needs that tool to be installed.

Cut, copy and paste a file or a folder with ⌘X / ⌘C / ⌘V or from the menu: pasting puts it inside the selected folder, or beside the selected file. A folder travels whole. Nothing is ever overwritten — a name already taken becomes name copy, then name copy 2 — and a folder pasted inside itself is refused instead of copying for ever.

The keyboard alone goes through the tree: the arrows move and open or close a folder, Home and End go to the first and last row, the page keys move ten at a time, typing a letter jumps to the next row whose name starts with it, Enter renames what is selected, ⌘⌫ (Delete elsewhere) moves it to the trash, ⇧F10 (or the menu key) opens the menu of what is selected, and ⌘A selects the text of the panel so it can be copied.

The other secondary menus

On the text: Go to Definition, Go to Type Definition, Call Hierarchy, Find References, Go to Implementation, Rename Symbol, Show Code Actions, Select All Current (every occurrence of what is under the cursor), Format Document, Format Selection, Organize Imports, Apply All Fixes, Ask the AI about the selection, Run in Terminal, Reveal in Panel, Reveal in File Explorer, Open on Remote, Cut / Copy / Paste, the palette, and Copy Path / Copy Relative Path of what is being edited. On a run.toml the menu is the short one plus Add Run/Debug Configuration.

Format Selection (⌘K ⌘F, Ctrl+K Ctrl+F) asks the language server to format only what is selected; with nothing selected it formats the whole document, and a server that does not offer range formatting says so instead of leaving the editor waiting.

On an editor tab: Close, Close Other Tabs, Close All Tabs, Close Tabs to the Right / to the Left, Close Saved Tabs, Pin Tab (Unpin when it is pinned), Split Right, Split Down, Reveal in Finder — the file manager of the system — , Copy Path and Copy Relative Path.

A pinned tab shows a pin where its close button is, sits to the left of the ones that are not pinned, survives every «close the rest» there is, and is still pinned the next time the editor opens.

On a terminal tab: Copy (off when nothing is selected), Paste, Select All, Find…, Clear All, Stop / Restart when it is running a task, and Close.

Selecting and copying in the panels

The text in the panels is text: drag across a problem's message, a search hit, a file's name or a plugin's description and ⌘C (Ctrl+C) copies it, without having to type it out again.

⌘A in the Problems or Search panel selects every row it shows, and ⌘C then copies the lot — one line per row, each saying its file and its line, the way a compiler writes it. A panel becomes the focused one when you click in it or select text in it; clicking a problem or a match opens the file, so the focus goes with it to the editor.

The panels, with the mouse

Problems: the secondary click copies the message, or the message with its file and line the way a compiler writes it, goes to the problem, and leaves only that file's problems in the panel until you ask for all of them again.

Search: copy the line, copy it with its place, go to the match, Replace Here — which replaces that one match, undoably, and is off while the replacement box is empty — and Dismiss This Result, which takes a hit off the list without touching the file.

Source Control: open the changes as a diff, take the file into the commit or leave it out, discard its changes, reveal it in the system's file manager, and copy its path or its relative path.

Debug: on a variable, copy its name, its value or both, change what it holds, and add it to Watch.

Plugins: the row's secondary click opens the same menu as the gear — reload, enable or disable (here or for this workspace), uninstall — and Open the Plugin's Folder.

Text objects (modal mode)

With core.modal = true, i and a after an operator — or in visual mode — mean «the thing the cursor is in»: ciw changes the word, di( takes out what is inside the brackets, ya" copies the string with its quotes, vip selects the paragraph. The objects are w and W (word and WORD), ", ', `, the brackets ((, [, {, <, and b and B), p (paragraph), s (the line) and t (the innermost XML/HTML tag).

Marks (modal mode)

m and then a letter remembers where the cursor is; ' and the same letter goes back to it. A lowercase letter belongs to the file it was set in, so ma in one file and ma in another are two different places. An uppercase letter belongs to the window: 'A opens the file it was set in, from wherever you are.

A mark is a line and a column, not a position in the bytes, so it keeps pointing at the same line while text is typed above it. A mark into a file that is no longer there is forgotten the first time it is followed.

Repeat and replace (modal mode)

. types the last change again — whatever it was: an operator with its motion, a text object, a line typed in insert mode. It is kept as the keys that made it, from the last time the editor was sitting in normal mode until it was back there with the text changed.

r and a character replaces the character under the cursor without leaving normal mode.

Macros (modal mode)

q and a letter starts recording what you type; q again stops it, and @ with the same letter plays it back. @@ plays the last one again. A macro can play another macro, four deep, which is enough to build one out of others and not enough to hang the editor with one that plays itself.

Registers (modal mode)

" and a letter says which register the next yank, delete or paste uses: "ayy keeps the line in a, "ap pastes it back, and what is in a stays there while yy and p go on using the unnamed one. An uppercase letter adds to the register instead of replacing it, so "Ayy on several lines collects them.

Emmet

In HTML, XML, JSX/TSX, Vue, Svelte and PHP, an abbreviation before the cursor expands into the markup it stands for: Emmet: Expand Abbreviation (Ctrl+Alt+E), and, when it has structure, as a proposal in the completion list, so Enter or Tab takes it.

ul>li.item*3              a list of three items, each with its class
div#main>p+span           an id, a child and its sibling
a[href=# target=_blank]   attributes, quoted or not
li.row$*3{fila $}         numbered: row1 … row3, «fila 1» … «fila 3»
(div>p)*2                 groups repeat everything inside them
table>tr>td               what a bare class means follows the context

Empty attributes and empty elements become the placeholders of a snippet, so Tab walks them, and the cursor ends after the last one. A void tag (img, br, input…) closes itself.

In CSS, SCSS and Less the abbreviations are Emmet's for declarations, offered first in the completion list and expanded by the same command:

m10-20        margin: 10px 20px;
w100p         width: 100%;         (p %, e em, x ex, r rem, or the unit written out)
fz1.5e        font-size: 1.5em;
df+aic+g8     display: flex; align-items: center; gap: 8px;
c#f           color: #fff;
posa          position: absolute;
m10!          margin: 10px !important;
bdrs          border-radius: |     (the cursor where the value goes)

Emmet: Wrap with Abbreviation… puts the selection — or the line, with nothing selected — inside what an abbreviation stands for: div.card wraps it in the div, section>div in the inner one, and a * with no number makes one copy per line, so ul>li* turns lines into a list.

Tags that close themselves

In those same languages, typing > after an opening tag writes the closing one and leaves the cursor between the two, and typing </ finishes it with whichever tag is still open. Void tags (br, img, input…) and tags that close themselves are left alone. editor.auto-closing-tags = false turns it off.

Large files

A file past 4 MB, or one with a line longer than 100 000 characters (a JSON written on a single line, a minified bundle), is opened without syntax highlighting, and past 16 MB no language server is told about it either. Parsing it would take seconds of a frozen window, and holding it in a server would take memory nobody asked for; what you usually want from a file that size is to look at it, which still works. The editor says what it turned off when it opens one.

Pictures

Opening a .png, .jpg, .gif, .webp, .svg, .ico or .avif — from the explorer, from the palette or from a link — shows the picture in a tab, with its size in pixels and what it weighs underneath, instead of the bytes that make it. It is a tab like any other, so it can be split beside the code that uses it.

Colours you can see

Every colour written in a file — #1e1e2e, #0f08, rgb(220, 30, 90), rgba(0, 128, 255, .5) — gets a bar in its own colour under the text that names it, over a thin outline so a dark colour on a dark theme is still there. What only looks like one is left alone: the #[derive] of a Rust file is not a colour. editor.color-swatches = false turns it off.

Colour: Pick a Colour… offers the colour under the cursor written the other ways — #ff0000, rgb(255 0 0), hsl(0 100% 50%) — and the named colours; Colour: Type a Colour… takes one in any of those forms. What is chosen replaces the colour under the cursor (a #hex, an rgb(), an hsl() or a name), or goes where the cursor is.

The minimap

The whole file is drawn small down the right-hand side: one bar per line at its indentation, the lines that differ from git in the colour of the gutter, the matches of the find bar picked out, and a box over the part you are looking at. Clicking or dragging it moves through the file. A file too long to fit slides under the box, so the map always shows where you are. editor.minimap = false turns it off and editor.minimap-width changes how wide it is.

Walking the changes

Go to Next Change (Alt+F5) and Go to Previous Change (Shift+Alt+F5) move the cursor through the places where the file differs from the version in git — the same changes the gutter marks. A block of added lines is one stop, a block that was deleted stops where the text carries on, and the last change leads back to the first.

Ctrl/Cmd+click on a terminal line opens what is under the pointer: a file with the line and column its tool printed — src/main.rs:12:5, src/a.ts(3,5), File "app.py", line 12, ./cmd/x.go:7:2, a plain path or a file:// URL — or a web address, in your browser. Paths are resolved against the terminal's working directory and then the workspace, and only offered when the file exists, so a word that merely looks like a path is left alone. With shell integration set up, the terminal's directory is the one the shell is really in, so the links of a program that changed directory still work.

See Terminal for finding in the scrollback and for running a recent command again.

Source: docs/editing.md