Implementation Plan: Pull an Object into the Workspace
This document defines the design and implementation for copying the scripts and
notecards of a published in-world object into a local workspace folder, and linking
the resulting files to their in-world counterparts.
Tracking issue: split from secondlife/sl-vscode-plugin#130 ("pull" half).
Related documents:
Overview
Today the only way to get an object's contents into the workspace is to open each
item from the Second Life explorer and save it somewhere by hand. slVscodeEdit.autoLinkObject
solves the matching half of the problem for files that already exist; this feature
covers the case where they do not.
Pull walks a published object, writes one local file per script and notecard into
a folder named for the object, mirrors child prims as subfolders, and links every
resulting file to the in-world item it came from.
The operation is read-only with respect to the viewer. It never writes in-world.
Goals
- Produce a complete, predictable on-disk snapshot of an object's text content.
- Mirror the linkset structure so items from different prims never collide.
- Link every copied file to its originating item, exactly, with no heuristics.
- Never damage existing workspace files unless the user explicitly opts in.
- Confine all filesystem writes to a single destination folder.
- Report one summary at the end, with per-item detail in the log.
Non-Goals
- Writing anything in-world.
- Reconstructing
#include / require structure from flattened source.
- Matching copied items against pre-existing workspace files (see
Design decision 1).
- Persisting links across sessions.
- Deleting or pruning local files that no longer exist in-world.
- Reporting orphaned local files — files in the destination with no matching
in-world item.
- Exporting non-text inventory (textures, sounds, animations, contained objects).
The protocol does not expose them.
Background
Two existing behaviours matter here.
Linking is item-driven and ephemeral. A link is created as a side effect of
opening an sl:// document, lives in ScriptSync.fileMappings keyed by
{ rootId, primId, itemId }, and is lost when the window closes. Nothing on disk
describes it. autoLinkObject re-establishes links in bulk by re-running the
matcher.
Matching is filename-based and ambiguous. SynchService.findMasterFile() tries,
in order: an @file metadata comment in the item's content, an exact
**/{name}.{ext} workspace search taking files[0], then a flattened-path
heuristic. With one object open this is usually right. Across several pulled
objects that each contain a trigger.luau, it is a coin toss.
Pull deliberately does not use the matcher. It knows the item_id → path mapping by
construction, because it created the files.
Design Decisions
These were settled during design review and are recorded so the rationale is not lost.
| # |
Decision |
Rationale |
| 1 |
Do not attempt to find an existing master. Always write into the object folder. |
findMasterFile answers "which file did the user mean when they opened one item?" Applying it to a bulk operation multiplies a recoverable guess into dozens of silent ones. Pulling window when door/trigger.luau already exists must not touch door. |
| 2 |
Two modes: safe (skip existing files) and overwrite. Safe is the default. |
Safe makes re-running the pull idempotent. Overwrite serves "refresh my local copy from in-world", which is a real workflow but must be a deliberate act. |
| 3 |
Mode is chosen at invocation, not in settings. |
A destructive mode reachable by a stale configuration value is a trap. |
| 4 |
All writes are confined to the destination folder. |
This is the entire safety story. It is an invariant, not a convention. |
| 5 |
Prompt for the destination folder, pre-filled with the sanitised object name. |
Solves three problems with one input box: multi-root workspaces, objects named Object colliding, and unnamed objects. |
| 6 |
Child prim folders are always suffixed with the link number, not only on collision. |
Conditional suffixing means adding a second prim named Door on a later pull renames the first one's folder and orphans its contents. |
| 7 |
Content is written verbatim apart from stripping the emitted metadata block. |
What is in-world may be preprocessor output. Pretending otherwise, or attempting to un-flatten it, would be a lie. The metadata block is stripped so pulled files do not claim to be a path they are not. |
| 8 |
Link by construction after writing; never by matching. |
The mapping is known exactly at write time. |
| 9 |
Re-running a pull is additive. Items deleted in-world leave local files behind, untouched and unreported. |
Deleting user files during an operation named "pull" would be a surprise. |
| 10 |
Notecards keep their SL name verbatim; no synthetic .txt is appended. |
The inventory name is the filename. Appending an extension makes the local name disagree with the item name for no benefit, and push would have to undo it. |
| 11 |
Always prompt for the workspace root, even when only one is open. |
One consistent flow, and the destination is stated explicitly before anything is written. |
| 12 |
No orphan reporting. |
"Orphan" cannot be distinguished from "a file the user put there" without durable persistence, which is out of scope. |
| 13 |
@line markers are preserved. |
They are not only preprocessor noise — they mark where required or included files were inlined, which is information the reader needs. |
Detailed Design
Command and surfaces
- Id:
slVscodeEdit.pullObjectToWorkspace
- Title:
Second Life: Pull Object to Workspace
- Scope: one published object, including the root prim and every linked prim.
Surfaces:
- Native tree context menu, on nodes with
contextValue of slObject.
- Webview explorer object "more actions" menu.
- Command palette. When invoked without an argument, prompt with a quick pick of
published objects; if exactly one is published, use it without prompting.
Invocation flow
- Resolve the target object from
ObjectContentService.getInstance().getObject(objectId).
Abort with a message if it is not published.
- Snapshot the object tree.
object.update sends full-replacement arrays and can
land mid-run; all subsequent steps operate on the snapshot.
- Prompt for the workspace root (quick pick, always shown, even when a single root is
open), then for the destination folder (input box, pre-filled with the sanitised
object name).
- Prompt for the mode (quick pick: Safe — skip existing files / Overwrite existing
files). Safe is the default item.
- If overwrite was chosen, enumerate which files would be overwritten and confirm with
a count (and names, when the count is small). Abort cleanly on cancel.
- Run the export inside
vscode.window.withProgress, cancellable: true, reporting
prim / item per step.
- Show the summary.
Destination layout
<destination>/
<root item files>
<sanitised child name>_<link_number>/
<child item files>
- Root prim items go directly in the destination folder.
- One subfolder per child prim, named
${sanitise(link_name)}_${link_number}.
- Child prims with empty inventories produce no folder.
Name sanitisation
SL names permit characters that filesystems do not. One shared helper handles both
prim folder names and item filenames:
- Replace
< > : " / \ | ? * and control characters 0x00–0x1F with _.
- Trim leading and trailing whitespace and trailing dots.
- Empty result becomes
unnamed.
- Reserved Windows device names (
CON, PRN, AUX, NUL, COM1–COM9,
LPT1–LPT9), with or without an extension, are prefixed with _.
- Truncate to a bounded length, preserving any extension.
- Resolve collisions within the same directory — including case-insensitive
collisions, since Windows and macOS filesystems do not distinguish Door from
door — by appending _2, _3, … Duplicate item names within a single prim are
handled by the same rule.
The helper is pure and has no VS Code dependency, so it is unit-testable directly.
File extensions
- Scripts:
.lsl or .luau, from languageForItem(item) in
src/vscode/objectcontentprovider.ts.
- Notecards: no synthetic extension.
displayName() already returns the name verbatim
and pull does the same, so the local filename equals the SL inventory name.
Content retrieval
Read through the existing virtual filesystem rather than calling the transport
directly:
const uri = itemUri(objectId, primId, item.item_id);
const content = Buffer.from(await vscode.workspace.fs.readFile(uri)).toString("utf-8");
This is what autoLinkObject already does. It reuses the provider's caching, error
mapping and notecard envelope stripping for free.
Before writing, strip the metadata block that ScriptSync emits on save (the
@file / creator comment lines — see the meta emission in
src/scriptsync.ts). Everything else is preserved verbatim,
including inlined include bodies and @line markers — the markers are kept
deliberately, because they show where required or included files were expanded.
Write rules
- Target path must resolve inside the destination folder. Enforce this after
sanitisation and normalisation; skip and log anything that escapes.
- Safe mode: if the target exists, skip. Do not read it, do not compare it.
- Overwrite mode: overwrite, except when the file is open with unsaved changes
(vscode.workspace.textDocuments with isDirty), in which case skip and report.
- Items whose content cannot be fetched are reported as unreadable and their existing
local file, if any, is left untouched. Overwrite mode is therefore not a refresh
guarantee, and the summary must say so.
Linking
After a file is written — or skipped because it already exists — link it to the
originating item. The mapping is exact; no matching is performed.
This requires a new seam. SynchService.linkSlItem() currently resolves the master
itself by calling findMasterFile partway through
(src/synchservice.ts). Extract everything after resolution:
private async linkSlItemToMaster(
uri: vscode.Uri,
content: string,
masterUri: vscode.Uri,
options: { reveal: boolean },
viewerDocument?: vscode.TextDocument,
): Promise<SlLinkResult>
linkSlItem() becomes resolve-then-delegate, so the single-item open path is
unchanged byte for byte. Pull calls linkSlItemToMaster with reveal: false.
Existing behaviour that carries over unchanged:
- No-modify items are not linked. No-modify notecards are still readable and are
therefore copied but not linked; they must be reported as such.
- A mismatch between the local file and the in-world content is counted, not prompted.
Bulk prompting is explicitly rejected (plan-autolink-object-files.md, decision 2).
Summary
One notification, with the detail written to the plugin log:
| Category |
Meaning |
written |
New file created. |
overwritten |
Existing file replaced (overwrite mode). |
skipped (exists) |
Target present, safe mode. |
skipped (open and modified) |
Dirty editor buffer, overwrite mode. |
unreadable |
Permissions or fetch failure; nothing written. |
linked |
Linked to the in-world item. |
copied, not linked |
No-modify item. |
differing |
Linked, but local content differs from in-world. |
failed |
Unexpected error; see log. |
The summary should also state plainly that pulled script content is what is stored
in-world, which for scripts previously pushed by this extension is preprocessor
output rather than the original modules.
Configuration
None. The mode is chosen at invocation (decision 3) and notecard names are used
verbatim (decision 10), so this feature contributes no new settings.
Error handling
| Condition |
Behaviour |
| Object not published |
Abort before any filesystem work, with a clear message. |
| No workspace open |
Abort with a message. |
| Object unpublished or deleted mid-run |
Stop, report a partial result. |
| Cancellation |
Stop after the current item; already-written files remain; report partial. |
| Destination folder cannot be created |
Abort before writing anything. |
| Individual item fetch failure or timeout |
Count as unreadable, continue. |
Implementation Phases
Each phase is independently shippable and independently reviewable.
Phase 0 — Shared helpers
- Add a path-safety module exporting
sanitiseSegment(name) and
uniqueInDirectory(name, taken), implementing the rules in
Name sanitisation. Pure functions, no VS Code imports.
- Add a
stripEmittedMeta(content, language) helper that removes the metadata block
ScriptSync emits, reusing the same comment-prefix resolution as
findMasterFileByMetaComment.
- Unit tests for both, covering reserved names, control characters, trailing dots,
empty names, case-insensitive collisions and truncation.
Phase 1 — Export core, safe mode, no linking
- Add
SynchService.pullObjectToWorkspace(objectId, options) modelled on
autoLinkObject: snapshot, flatten root plus linked prims into a work list,
withProgress loop with cancellation, accumulate a summary.
- Implement destination resolution — the always-shown workspace root quick pick,
followed by the pre-filled folder input box.
- Implement the layout: root items at the destination,
name_linknumber subfolders
for children, empty-inventory prims skipped.
- Implement fetch → strip metadata → write, with the containment check and the safe
mode skip rule.
- Implement the summary notification and log detail.
- Register the command in src/extension.ts alongside
slVscodeEdit.autoLinkObject, and contribute it in package.json for the command
palette and view/item/context on slObject.
Phase 2 — Exact linking
- Extract
linkSlItemToMaster from linkSlItem with no behavioural change; confirm
the existing open-a-single-item path still behaves identically.
- Call it for every written and skipped-because-exists file, with
reveal: false.
- Count
linked, copied, not linked and differing in the summary.
- Refresh the synced-file decorator once at the end rather than per file.
Phase 3 — Overwrite mode and guardrails
- Add the mode quick pick.
- Add the pre-flight enumeration and confirmation for overwrite.
- Add the dirty-buffer skip.
- Distinguish
overwritten from unreadable in the summary so a partial refresh is
never mistaken for a complete one.
Phase 4 — Surfaces and polish
- Add the action to the webview object menu in
src/webview/explorer/explorer.ts and handle
the message in src/vscode/objectexplorerwebview.ts,
following the autoLinkObject precedent.
- Update features-overview.md and
USER_GUIDE.md.
Testing
Unit — sanitisation and metadata stripping, exhaustively. These are pure and cheap
to cover.
Integration, with a stubbed ObjectContentService and an in-memory or temp
workspace:
- Root-only object; root plus multiple children.
- Two children with identical names; two items with identical names in one prim.
- Names containing
/, :, *, trailing dots, and a reserved device name.
- Empty child inventory produces no folder.
- Safe mode leaves existing file content byte-identical.
- Overwrite mode replaces content, and skips a dirty buffer.
- Unreadable item is counted and leaves no file.
- Cancellation mid-run yields a partial result with files already written intact.
- A path that would escape the destination after sanitisation is rejected.
Manual, against a viewer:
- No-copy and no-modify scripts are reported, not silently missing.
- A no-modify notecard is copied but not linked.
- After pull, saving a pulled file pushes to the correct in-world item.
- A linkset change during a pull does not corrupt the traversal.
Future Work
Out of scope here, recorded so the reasoning is not lost:
- Explicit re-link. Bind a chosen workspace file to a specific in-world item,
bypassing findMasterFile. Pull manufactures many same-named files across object
folders, and session links are ephemeral, so on the next session autoLinkObject
has to guess between them. Likely surfaces as "Link to File…" on an explorer item
and "Link to In-World Item…" on a workspace file.
- Durable link persistence — a manifest recording
item_id → relative path. The
natural companion to re-link, and a prerequisite for the push feature.
- Backup-then-overwrite, and a "show me the differences" mode that opens diffs
instead of writing.
- Selective pull — choose a subset of items rather than all-or-nothing.
- Orphan reporting — distinguishing a stale pulled file from one the user created
requires the durable persistence described above.
Implementation Plan: Pull an Object into the Workspace
This document defines the design and implementation for copying the scripts and
notecards of a published in-world object into a local workspace folder, and linking
the resulting files to their in-world counterparts.
Tracking issue: split from secondlife/sl-vscode-plugin#130 ("pull" half).
Related documents:
Overview
Today the only way to get an object's contents into the workspace is to open each
item from the Second Life explorer and save it somewhere by hand.
slVscodeEdit.autoLinkObjectsolves the matching half of the problem for files that already exist; this feature
covers the case where they do not.
Pull walks a published object, writes one local file per script and notecard into
a folder named for the object, mirrors child prims as subfolders, and links every
resulting file to the in-world item it came from.
The operation is read-only with respect to the viewer. It never writes in-world.
Goals
Non-Goals
#include/requirestructure from flattened source.Design decision 1).
in-world item.
The protocol does not expose them.
Background
Two existing behaviours matter here.
Linking is item-driven and ephemeral. A link is created as a side effect of
opening an
sl://document, lives inScriptSync.fileMappingskeyed by{ rootId, primId, itemId }, and is lost when the window closes. Nothing on diskdescribes it.
autoLinkObjectre-establishes links in bulk by re-running thematcher.
Matching is filename-based and ambiguous.
SynchService.findMasterFile()tries,in order: an
@filemetadata comment in the item's content, an exact**/{name}.{ext}workspace search takingfiles[0], then a flattened-pathheuristic. With one object open this is usually right. Across several pulled
objects that each contain a
trigger.luau, it is a coin toss.Pull deliberately does not use the matcher. It knows the
item_id→ path mapping byconstruction, because it created the files.
Design Decisions
These were settled during design review and are recorded so the rationale is not lost.
findMasterFileanswers "which file did the user mean when they opened one item?" Applying it to a bulk operation multiplies a recoverable guess into dozens of silent ones. Pullingwindowwhendoor/trigger.luaualready exists must not touchdoor.Objectcolliding, and unnamed objects.Dooron a later pull renames the first one's folder and orphans its contents..txtis appended.@linemarkers are preserved.Detailed Design
Command and surfaces
slVscodeEdit.pullObjectToWorkspaceSecond Life: Pull Object to WorkspaceSurfaces:
contextValueofslObject.published objects; if exactly one is published, use it without prompting.
Invocation flow
ObjectContentService.getInstance().getObject(objectId).Abort with a message if it is not published.
object.updatesends full-replacement arrays and canland mid-run; all subsequent steps operate on the snapshot.
open), then for the destination folder (input box, pre-filled with the sanitised
object name).
files). Safe is the default item.
a count (and names, when the count is small). Abort cleanly on cancel.
vscode.window.withProgress,cancellable: true, reportingprim / itemper step.Destination layout
${sanitise(link_name)}_${link_number}.Name sanitisation
SL names permit characters that filesystems do not. One shared helper handles both
prim folder names and item filenames:
< > : " / \ | ? *and control characters0x00–0x1Fwith_.unnamed.CON,PRN,AUX,NUL,COM1–COM9,LPT1–LPT9), with or without an extension, are prefixed with_.collisions, since Windows and macOS filesystems do not distinguish
Doorfromdoor— by appending_2,_3, … Duplicate item names within a single prim arehandled by the same rule.
The helper is pure and has no VS Code dependency, so it is unit-testable directly.
File extensions
.lslor.luau, fromlanguageForItem(item)insrc/vscode/objectcontentprovider.ts.
displayName()already returns the name verbatimand pull does the same, so the local filename equals the SL inventory name.
Content retrieval
Read through the existing virtual filesystem rather than calling the transport
directly:
This is what
autoLinkObjectalready does. It reuses the provider's caching, errormapping and notecard envelope stripping for free.
Before writing, strip the metadata block that
ScriptSyncemits on save (the@file/ creator comment lines — see the meta emission insrc/scriptsync.ts). Everything else is preserved verbatim,
including inlined include bodies and
@linemarkers — the markers are keptdeliberately, because they show where required or included files were expanded.
Write rules
sanitisation and normalisation; skip and log anything that escapes.
(
vscode.workspace.textDocumentswithisDirty), in which case skip and report.local file, if any, is left untouched. Overwrite mode is therefore not a refresh
guarantee, and the summary must say so.
Linking
After a file is written — or skipped because it already exists — link it to the
originating item. The mapping is exact; no matching is performed.
This requires a new seam.
SynchService.linkSlItem()currently resolves the masteritself by calling
findMasterFilepartway through(src/synchservice.ts). Extract everything after resolution:
linkSlItem()becomes resolve-then-delegate, so the single-item open path isunchanged byte for byte. Pull calls
linkSlItemToMasterwithreveal: false.Existing behaviour that carries over unchanged:
therefore copied but not linked; they must be reported as such.
Bulk prompting is explicitly rejected (
plan-autolink-object-files.md, decision 2).Summary
One notification, with the detail written to the plugin log:
writtenoverwrittenskipped (exists)skipped (open and modified)unreadablelinkedcopied, not linkeddifferingfailedThe summary should also state plainly that pulled script content is what is stored
in-world, which for scripts previously pushed by this extension is preprocessor
output rather than the original modules.
Configuration
None. The mode is chosen at invocation (decision 3) and notecard names are used
verbatim (decision 10), so this feature contributes no new settings.
Error handling
unreadable, continue.Implementation Phases
Each phase is independently shippable and independently reviewable.
Phase 0 — Shared helpers
sanitiseSegment(name)anduniqueInDirectory(name, taken), implementing the rules inName sanitisation. Pure functions, no VS Code imports.
stripEmittedMeta(content, language)helper that removes the metadata blockScriptSyncemits, reusing the same comment-prefix resolution asfindMasterFileByMetaComment.empty names, case-insensitive collisions and truncation.
Phase 1 — Export core, safe mode, no linking
SynchService.pullObjectToWorkspace(objectId, options)modelled onautoLinkObject: snapshot, flatten root plus linked prims into a work list,withProgressloop with cancellation, accumulate a summary.followed by the pre-filled folder input box.
name_linknumbersubfoldersfor children, empty-inventory prims skipped.
mode skip rule.
slVscodeEdit.autoLinkObject, and contribute it inpackage.jsonfor the commandpalette and
view/item/contextonslObject.Phase 2 — Exact linking
linkSlItemToMasterfromlinkSlItemwith no behavioural change; confirmthe existing open-a-single-item path still behaves identically.
reveal: false.linked,copied, not linkedanddifferingin the summary.Phase 3 — Overwrite mode and guardrails
overwrittenfromunreadablein the summary so a partial refresh isnever mistaken for a complete one.
Phase 4 — Surfaces and polish
src/webview/explorer/explorer.ts and handle
the message in src/vscode/objectexplorerwebview.ts,
following the
autoLinkObjectprecedent.USER_GUIDE.md.
Testing
Unit — sanitisation and metadata stripping, exhaustively. These are pure and cheap
to cover.
Integration, with a stubbed
ObjectContentServiceand an in-memory or tempworkspace:
/,:,*, trailing dots, and a reserved device name.Manual, against a viewer:
Future Work
Out of scope here, recorded so the reasoning is not lost:
bypassing
findMasterFile. Pull manufactures many same-named files across objectfolders, and session links are ephemeral, so on the next session
autoLinkObjecthas to guess between them. Likely surfaces as "Link to File…" on an explorer item
and "Link to In-World Item…" on a workspace file.
item_id→ relative path. Thenatural companion to re-link, and a prerequisite for the push feature.
instead of writing.
requires the durable persistence described above.