mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-04-20 20:55:50 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bf9be6ccae | ||
|
|
974ebc33c2 | ||
|
|
4b03a79cfe | ||
|
|
43cc350b7a | ||
|
|
5685619c3a | ||
|
|
f3483ee2e0 | ||
|
|
60a9135452 | ||
|
|
c3d6dc0e2c | ||
|
|
2681972976 | ||
|
|
5e64773784 | ||
|
|
c7171ef513 | ||
|
|
63b21b90ff | ||
|
|
7f3aaf4680 |
12
CHANGES.md
12
CHANGES.md
@@ -3,5 +3,17 @@
|
|||||||
## Current Master
|
## Current Master
|
||||||
* *No changes*
|
* *No changes*
|
||||||
|
|
||||||
|
## 0.2.2
|
||||||
|
* Plugin only release
|
||||||
|
* Fixed broken reconciliation behavior with `init` files
|
||||||
|
|
||||||
|
## 0.2.1
|
||||||
|
* Plugin only release
|
||||||
|
* Changes default port to 8000
|
||||||
|
|
||||||
|
## 0.2.0
|
||||||
|
* Support for `init.lua` like rbxfs and rbxpacker
|
||||||
|
* More robust syncing with a new reconciler
|
||||||
|
|
||||||
## 0.1.0
|
## 0.1.0
|
||||||
* Initial release, functionally very similar to [rbxfs](https://github.com/LPGhatguy/rbxfs)
|
* Initial release, functionally very similar to [rbxfs](https://github.com/LPGhatguy/rbxfs)
|
||||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -1,6 +1,6 @@
|
|||||||
[root]
|
[root]
|
||||||
name = "rojo"
|
name = "rojo"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"clap 2.28.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
"clap 2.28.0 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||||
"notify 4.0.3 (registry+https://github.com/rust-lang/crates.io-index)",
|
"notify 4.0.3 (registry+https://github.com/rust-lang/crates.io-index)",
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "rojo"
|
name = "rojo"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
authors = ["Lucien Greathouse <me@lpghatguy.com>"]
|
authors = ["Lucien Greathouse <me@lpghatguy.com>"]
|
||||||
description = "A tool to create robust Roblox projects"
|
description = "A tool to create robust Roblox projects"
|
||||||
|
license = "MIT"
|
||||||
|
repository = "https://github.com/LPGhatguy/rojo"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "rojo"
|
name = "rojo"
|
||||||
|
|||||||
123
README.md
123
README.md
@@ -3,53 +3,37 @@
|
|||||||
<a href="https://travis-ci.org/LPGhatguy/Rojo">
|
<a href="https://travis-ci.org/LPGhatguy/Rojo">
|
||||||
<img src="https://api.travis-ci.org/LPGhatguy/Rojo.svg?branch=master" alt="Travis-CI Build Status" />
|
<img src="https://api.travis-ci.org/LPGhatguy/Rojo.svg?branch=master" alt="Travis-CI Build Status" />
|
||||||
</a>
|
</a>
|
||||||
<a href="#">
|
|
||||||
<img src="https://img.shields.io/badge/docs-soon-red.svg" alt="Documentation" />
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div> </div>
|
<div> </div>
|
||||||
|
|
||||||
**EARLY DEVELOPMENT, USE WITH CARE**
|
Rojo is a flexible multi-tool designed for creating robust Roblox projects. It's in early development, but is still useful for many projects.
|
||||||
|
|
||||||
Rojo is a flexible multi-tool designed for creating robust Roblox projects.
|
It's designed for power users who want to use the **best tools available** for building games, libraries, and plugins.
|
||||||
|
|
||||||
It's designed for power users who want to use the best tools available for building games, libraries, and plugins.
|
## Features
|
||||||
|
|
||||||
It has a number of desirable features *right now*:
|
Rojo has a number of desirable features *right now*:
|
||||||
|
|
||||||
* Work from the filesystem, in your favorite editor
|
* Work on scripts from the filesystem, in your favorite editor
|
||||||
* Version your place, library, or plugin using Git or another VCS
|
* Version your place, library, or plugin using Git or another VCS
|
||||||
|
|
||||||
Soon, Rojo will be able to:
|
Soon, Rojo will be able to:
|
||||||
|
|
||||||
|
* Sync Roblox objects (including models) bi-directionally between the filesystem and Roblox Studio
|
||||||
* Create installation scripts for libraries to be used in standalone places
|
* Create installation scripts for libraries to be used in standalone places
|
||||||
* Similar to [rbxpacker](https://github.com/LPGhatguy/rbxpacker), another one of my projects
|
* Similar to [rbxpacker](https://github.com/LPGhatguy/rbxpacker), another one of my projects
|
||||||
* Add strongly-versioned dependencies to your project
|
* Add strongly-versioned dependencies to your project
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
Rojo has two components:
|
Rojo has two components:
|
||||||
* The binary, written in Rust
|
* The command line tool, written in Rust
|
||||||
* The [Roblox Studio plugin](https://www.roblox.com/library/1211549683/Rojo-v0-0-0), written in Lua
|
* The [Roblox Studio plugin](https://www.roblox.com/library/1211549683/Rojo-v0-0-0), written in Lua
|
||||||
|
|
||||||
To install the binary, there are two options:
|
To install the command line tool, there are two options:
|
||||||
* Cargo, which requires you to have Rust installed
|
* Cargo, if you have Rust installed
|
||||||
* Pre-built binaries from the [the GitHub releases page](https://github.com/LPGhatguy/rojo/releases)
|
* Use `cargo install rojo` -- Rojo will be available with the `rojo` command
|
||||||
|
* Download a pre-built Windows binary from [the GitHub releases page](https://github.com/LPGhatguy/rojo/releases)
|
||||||
### Cargo (Recommended)
|
|
||||||
Make sure you have [Rust 1.21 or newer](https://www.rust-lang.org/) installed.
|
|
||||||
|
|
||||||
Install Rojo using:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
cargo install rojo
|
|
||||||
|
|
||||||
# Installed!
|
|
||||||
rojo help
|
|
||||||
```
|
|
||||||
|
|
||||||
### Pre-Built (Windows only)
|
|
||||||
Download the latest binary from [the GitHub releases page](https://github.com/LPGhatguy/rojo/releases). Put it somewhere you can access it from a terminal!
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
For more help, use `rojo help`.
|
For more help, use `rojo help`.
|
||||||
@@ -66,8 +50,91 @@ rojo init
|
|||||||
|
|
||||||
Rojo will create an empty project in the directory.
|
Rojo will create an empty project in the directory.
|
||||||
|
|
||||||
|
The default project looks like this:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "my-new-project",
|
||||||
|
"servePort": 8000,
|
||||||
|
"partitions": {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Start Dev Server
|
||||||
|
To create a server that allows the Rojo Dev Plugin to access your project, use:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
rojo serve
|
||||||
|
```
|
||||||
|
|
||||||
|
The tool will tell you whether it found an existing project. You should then be able to connect and use the project from within Roblox Studio!
|
||||||
|
|
||||||
### Migrating an Existing Roblox Project
|
### Migrating an Existing Roblox Project
|
||||||
Coming soon!
|
**Coming soon!**
|
||||||
|
|
||||||
|
### Syncing into Roblox
|
||||||
|
In order to sync code into Roblox, you'll need to add one or more "partitions" to your configuration. A partition tells Rojo how to map directories to Roblox objects.
|
||||||
|
|
||||||
|
Each entry in the partitions table has a unique name, a filesystem path, and the full name of the Roblox object to sync into.
|
||||||
|
|
||||||
|
For example, if you want to map your `src` directory to an object named `My Cool Game` in `ReplicatedStorage`, you could use this configuration:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "rojo",
|
||||||
|
"servePort": 8000,
|
||||||
|
"partitions": {
|
||||||
|
"game": {
|
||||||
|
"path": "src",
|
||||||
|
"target": "ReplicatedStorage.My Cool Game"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The `path` parameter is relative to the project file.
|
||||||
|
|
||||||
|
The `target` starts at `game` and crawls down the tree. If any objects don't exist along the way, they'll be created as `Folder` instances.
|
||||||
|
|
||||||
|
Run `rojo serve` in the directory containing this project, then press the "Sync In" or "Toggle Polling" buttons in the Roblox Studio plugin to move code into your game.
|
||||||
|
|
||||||
|
### Sync Details
|
||||||
|
The structure of files and folders on the filesystem are preserved when syncing into game.
|
||||||
|
|
||||||
|
Creation of Roblox instances follows a simple set of rules. The first rule that matches the file name is chosen:
|
||||||
|
|
||||||
|
| File Name | Instance Type | Notes |
|
||||||
|
| -------------- | -------------- | ----------------------------------------- |
|
||||||
|
| `*.server.lua` | `Script` | `Source` will contain the file's contents |
|
||||||
|
| `*.client.lua` | `LocalScript` | `Source` will contain the file's contents |
|
||||||
|
| `*.lua` | `ModuleScript` | `Source` will contain the file's contents |
|
||||||
|
| `*` | `StringValue` | `Value` will contain the file's contents |
|
||||||
|
|
||||||
|
Any folders on the filesystem will turn into `Folder` objects unless they contain a file named `init` with any extension. Following the convention of Lua, those objects will instead be whatever the `init` file would turn into.
|
||||||
|
|
||||||
|
For example, this file tree:
|
||||||
|
|
||||||
|
* my-game
|
||||||
|
* init.lua
|
||||||
|
* foo.lua
|
||||||
|
|
||||||
|
Will turn into this tree in Roblox:
|
||||||
|
|
||||||
|
* `my-game` (`ModuleScript` with source from `my-game/init.lua`)
|
||||||
|
* `foo` (`ModuleScript` with source from `my-game/foo.lua`)
|
||||||
|
|
||||||
|
## Inspiration
|
||||||
|
There are lots of other tools that sync scripts into Roblox, or otherwise work to improve the development flow outside of Roblox Studio.
|
||||||
|
|
||||||
|
Here are a few, if you're looking for alternatives or supplements to Rojo:
|
||||||
|
* [rbxmk by Anaminus](https://github.com/anaminus/rbxmk)
|
||||||
|
* [RbxRefresh by Osyris](https://github.com/osyrisrblx/RbxRefresh)
|
||||||
|
* [RbxSync by evaera](https://github.com/evaera/RbxSync)
|
||||||
|
* [CodeSync](https://github.com/MemoryPenguin/CodeSync) and [rbx-exteditor](https://github.com/MemoryPenguin/rbx-exteditor) by [MemoryPenguin](https://github.com/MemoryPenguin)
|
||||||
|
|
||||||
|
I also have a couple tools that Rojo intends to replace:
|
||||||
|
* [rbxfs](https://github.com/LPGhatguy/rbxfs), which has been deprecated by Rojo
|
||||||
|
* [rbxpacker](https://github.com/LPGhatguy/rbxpacker), which is still useful
|
||||||
|
|
||||||
## License
|
## License
|
||||||
Rojo is available under the terms of the MIT license. See [LICENSE.md](LICENSE.md) for details.
|
Rojo is available under the terms of the MIT license. See [LICENSE.md](LICENSE.md) for details.
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
{
|
|
||||||
"rootDirectory": "src",
|
|
||||||
"rootObject": "ReplicatedStorage.Rojo"
|
|
||||||
}
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
return {
|
return {
|
||||||
pollingRate = 0.3,
|
pollingRate = 0.3,
|
||||||
|
version = "0.2.2",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
local HttpService = game:GetService("HttpService")
|
local HttpService = game:GetService("HttpService")
|
||||||
|
|
||||||
|
local HTTP_DEBUG = false
|
||||||
|
|
||||||
local Promise = require(script.Parent.Promise)
|
local Promise = require(script.Parent.Promise)
|
||||||
local HttpError = require(script.Parent.HttpError)
|
local HttpError = require(script.Parent.HttpError)
|
||||||
local HttpResponse = require(script.Parent.HttpResponse)
|
local HttpResponse = require(script.Parent.HttpResponse)
|
||||||
|
|
||||||
|
local function dprint(...)
|
||||||
|
if HTTP_DEBUG then
|
||||||
|
print(...)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
local Http = {}
|
local Http = {}
|
||||||
Http.__index = Http
|
Http.__index = Http
|
||||||
|
|
||||||
@@ -20,6 +28,7 @@ function Http.new(baseUrl)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function Http:get(endpoint)
|
function Http:get(endpoint)
|
||||||
|
dprint("\nGET", endpoint)
|
||||||
return Promise.new(function(resolve, reject)
|
return Promise.new(function(resolve, reject)
|
||||||
spawn(function()
|
spawn(function()
|
||||||
local ok, result = pcall(function()
|
local ok, result = pcall(function()
|
||||||
@@ -27,6 +36,7 @@ function Http:get(endpoint)
|
|||||||
end)
|
end)
|
||||||
|
|
||||||
if ok then
|
if ok then
|
||||||
|
dprint("\t", result, "\n")
|
||||||
resolve(HttpResponse.new(result))
|
resolve(HttpResponse.new(result))
|
||||||
else
|
else
|
||||||
reject(HttpError.fromErrorString(result))
|
reject(HttpError.fromErrorString(result))
|
||||||
@@ -36,6 +46,8 @@ function Http:get(endpoint)
|
|||||||
end
|
end
|
||||||
|
|
||||||
function Http:post(endpoint, body)
|
function Http:post(endpoint, body)
|
||||||
|
dprint("\nPOST", endpoint)
|
||||||
|
dprint(body)
|
||||||
return Promise.new(function(resolve, reject)
|
return Promise.new(function(resolve, reject)
|
||||||
spawn(function()
|
spawn(function()
|
||||||
local ok, result = pcall(function()
|
local ok, result = pcall(function()
|
||||||
@@ -43,6 +55,7 @@ function Http:post(endpoint, body)
|
|||||||
end)
|
end)
|
||||||
|
|
||||||
if ok then
|
if ok then
|
||||||
|
dprint("\t", result, "\n")
|
||||||
resolve(HttpResponse.new(result))
|
resolve(HttpResponse.new(result))
|
||||||
else
|
else
|
||||||
reject(HttpError.fromErrorString(result))
|
reject(HttpError.fromErrorString(result))
|
||||||
|
|||||||
@@ -3,26 +3,36 @@ if not plugin then
|
|||||||
end
|
end
|
||||||
|
|
||||||
local Plugin = require(script.Parent.Plugin)
|
local Plugin = require(script.Parent.Plugin)
|
||||||
|
local Config = require(script.Parent.Config)
|
||||||
|
|
||||||
local function main()
|
local function main()
|
||||||
local pluginInstance = Plugin.new()
|
local pluginInstance = Plugin.new()
|
||||||
|
|
||||||
local toolbar = plugin:CreateToolbar("Rojo Plugin 0.1.0")
|
local toolbar = plugin:CreateToolbar("Rojo Plugin v" .. Config.version)
|
||||||
|
|
||||||
toolbar:CreateButton("Test Connection", "Connect to Rojo Server", "")
|
toolbar:CreateButton("Test Connection", "Connect to Rojo Server", "")
|
||||||
.Click:Connect(function()
|
.Click:Connect(function()
|
||||||
pluginInstance:connect()
|
pluginInstance:connect()
|
||||||
|
:catch(function(err)
|
||||||
|
warn(err)
|
||||||
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
toolbar:CreateButton("Sync In", "Sync into Roblox Studio", "")
|
toolbar:CreateButton("Sync In", "Sync into Roblox Studio", "")
|
||||||
.Click:Connect(function()
|
.Click:Connect(function()
|
||||||
pluginInstance:syncIn()
|
pluginInstance:syncIn()
|
||||||
|
:catch(function(err)
|
||||||
|
warn(err)
|
||||||
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
toolbar:CreateButton("Toggle Polling", "Poll server for changes", "")
|
toolbar:CreateButton("Toggle Polling", "Poll server for changes", "")
|
||||||
.Click:Connect(function()
|
.Click:Connect(function()
|
||||||
spawn(function()
|
spawn(function()
|
||||||
pluginInstance:togglePolling()
|
pluginInstance:togglePolling()
|
||||||
|
:catch(function(err)
|
||||||
|
warn(err)
|
||||||
|
end)
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ local Config = require(script.Parent.Config)
|
|||||||
local Http = require(script.Parent.Http)
|
local Http = require(script.Parent.Http)
|
||||||
local Server = require(script.Parent.Server)
|
local Server = require(script.Parent.Server)
|
||||||
local Promise = require(script.Parent.Promise)
|
local Promise = require(script.Parent.Promise)
|
||||||
|
local Reconciler = require(script.Parent.Reconciler)
|
||||||
|
|
||||||
local function collectMatch(source, pattern)
|
local function collectMatch(source, pattern)
|
||||||
local result = {}
|
local result = {}
|
||||||
@@ -13,91 +14,12 @@ local function collectMatch(source, pattern)
|
|||||||
return result
|
return result
|
||||||
end
|
end
|
||||||
|
|
||||||
local function fileToName(filename)
|
|
||||||
if filename:find("%.server%.lua$") then
|
|
||||||
return filename:match("^(.-)%.server%.lua$"), "Script"
|
|
||||||
elseif filename:find("%.client%.lua$") then
|
|
||||||
return filename:match("^(.-)%.client%.lua$"), "LocalScript"
|
|
||||||
elseif filename:find("%.lua") then
|
|
||||||
return filename:match("^(.-)%.lua$"), "ModuleScript"
|
|
||||||
else
|
|
||||||
return filename, "StringValue"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local function nameToInstance(filename, contents)
|
|
||||||
local name, className = fileToName(filename)
|
|
||||||
|
|
||||||
local instance = Instance.new(className)
|
|
||||||
instance.Name = name
|
|
||||||
|
|
||||||
if className:find("Script$") then
|
|
||||||
instance.Source = contents
|
|
||||||
else
|
|
||||||
instance.Value = contents
|
|
||||||
end
|
|
||||||
|
|
||||||
return instance
|
|
||||||
end
|
|
||||||
|
|
||||||
local function make(item, name)
|
|
||||||
if item.type == "dir" then
|
|
||||||
local instance = Instance.new("Folder")
|
|
||||||
instance.Name = name
|
|
||||||
|
|
||||||
for childName, child in pairs(item.children) do
|
|
||||||
make(child, childName).Parent = instance
|
|
||||||
end
|
|
||||||
|
|
||||||
return instance
|
|
||||||
elseif item.type == "file" then
|
|
||||||
return nameToInstance(name, item.contents)
|
|
||||||
else
|
|
||||||
error("not implemented")
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local function write(parent, route, item)
|
|
||||||
local location = parent
|
|
||||||
|
|
||||||
for index = 1, #route - 1 do
|
|
||||||
local piece = route[index]
|
|
||||||
local newLocation = location:FindFirstChild(piece)
|
|
||||||
|
|
||||||
if not newLocation then
|
|
||||||
newLocation = Instance.new("Folder")
|
|
||||||
newLocation.Name = piece
|
|
||||||
newLocation.Parent = location
|
|
||||||
end
|
|
||||||
|
|
||||||
location = newLocation
|
|
||||||
end
|
|
||||||
|
|
||||||
local fileName = route[#route]
|
|
||||||
local name = fileToName(fileName)
|
|
||||||
|
|
||||||
local existing = location:FindFirstChild(name)
|
|
||||||
|
|
||||||
local new
|
|
||||||
if item then
|
|
||||||
new = make(item, fileName)
|
|
||||||
end
|
|
||||||
|
|
||||||
if existing then
|
|
||||||
existing:Destroy()
|
|
||||||
end
|
|
||||||
|
|
||||||
if new then
|
|
||||||
new.Parent = location
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
local Plugin = {}
|
local Plugin = {}
|
||||||
Plugin.__index = Plugin
|
Plugin.__index = Plugin
|
||||||
|
|
||||||
function Plugin.new()
|
function Plugin.new()
|
||||||
local address = "localhost"
|
local address = "localhost"
|
||||||
local port = 8081
|
local port = 8000
|
||||||
|
|
||||||
local remote = ("http://%s:%d"):format(address, port)
|
local remote = ("http://%s:%d"):format(address, port)
|
||||||
|
|
||||||
@@ -149,7 +71,7 @@ end
|
|||||||
function Plugin:connect()
|
function Plugin:connect()
|
||||||
print("Testing connection...")
|
print("Testing connection...")
|
||||||
|
|
||||||
self:server()
|
return self:server()
|
||||||
:andThen(function(server)
|
:andThen(function(server)
|
||||||
return server:getInfo()
|
return server:getInfo()
|
||||||
end)
|
end)
|
||||||
@@ -163,8 +85,10 @@ end
|
|||||||
function Plugin:togglePolling()
|
function Plugin:togglePolling()
|
||||||
if self._polling then
|
if self._polling then
|
||||||
self:stopPolling()
|
self:stopPolling()
|
||||||
|
|
||||||
|
return Promise.resolve(nil)
|
||||||
else
|
else
|
||||||
self:startPolling()
|
return self:startPolling()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -173,12 +97,30 @@ function Plugin:stopPolling()
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
print("Stopping polling...")
|
print("Stopped polling.")
|
||||||
|
|
||||||
self._polling = false
|
self._polling = false
|
||||||
self._label.Enabled = false
|
self._label.Enabled = false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function Plugin:_pull(server, project, routes)
|
||||||
|
local items = server:read(routes):await()
|
||||||
|
|
||||||
|
for index = 1, #routes do
|
||||||
|
local route = routes[index]
|
||||||
|
local partitionName = route[1]
|
||||||
|
local partition = project.partitions[partitionName]
|
||||||
|
local item = items[index]
|
||||||
|
|
||||||
|
local fullRoute = collectMatch(partition.target, "[^.]+")
|
||||||
|
for i = 2, #route do
|
||||||
|
table.insert(fullRoute, routes[index][i])
|
||||||
|
end
|
||||||
|
|
||||||
|
Reconciler.reconcileRoute(fullRoute, item)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
function Plugin:startPolling()
|
function Plugin:startPolling()
|
||||||
if self._polling then
|
if self._polling then
|
||||||
return
|
return
|
||||||
@@ -204,17 +146,7 @@ function Plugin:startPolling()
|
|||||||
table.insert(routes, change.route)
|
table.insert(routes, change.route)
|
||||||
end
|
end
|
||||||
|
|
||||||
local items = server:read(routes):await()
|
self:_pull(server, project, routes)
|
||||||
|
|
||||||
for index = 1, #routes do
|
|
||||||
local partitionName = routes[index][1]
|
|
||||||
local partition = project.partitions[partitionName]
|
|
||||||
local data = items[index]
|
|
||||||
|
|
||||||
local fullRoute = collectMatch(partition.target, "[^.]+")
|
|
||||||
|
|
||||||
write(game, fullRoute, data)
|
|
||||||
end
|
|
||||||
|
|
||||||
wait(Config.pollingRate)
|
wait(Config.pollingRate)
|
||||||
end
|
end
|
||||||
@@ -231,23 +163,13 @@ function Plugin:syncIn()
|
|||||||
:andThen(function(server)
|
:andThen(function(server)
|
||||||
local project = server:getInfo():await().project
|
local project = server:getInfo():await().project
|
||||||
|
|
||||||
local readRoutes = {}
|
local routes = {}
|
||||||
|
|
||||||
for name in pairs(project.partitions) do
|
for name in pairs(project.partitions) do
|
||||||
table.insert(readRoutes, {name})
|
table.insert(routes, {name})
|
||||||
end
|
end
|
||||||
|
|
||||||
local items = server:read(readRoutes):await()
|
self:_pull(server, project, routes)
|
||||||
|
|
||||||
for index = 1, #readRoutes do
|
|
||||||
local partitionName = readRoutes[index][1]
|
|
||||||
local partition = project.partitions[partitionName]
|
|
||||||
local data = items[index]
|
|
||||||
|
|
||||||
local fullRoute = collectMatch(partition.target, "[^.]+")
|
|
||||||
|
|
||||||
write(game, fullRoute, data)
|
|
||||||
end
|
|
||||||
|
|
||||||
print("Sync successful!")
|
print("Sync successful!")
|
||||||
end)
|
end)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
An implementation of Promises similar to Promise/A+.
|
An implementation of Promises similar to Promise/A+.
|
||||||
]]
|
]]
|
||||||
|
|
||||||
local PROMISE_DEBUG = true
|
local PROMISE_DEBUG = false
|
||||||
|
|
||||||
-- If promise debugging is on, use a version of pcall that warns on failure.
|
-- If promise debugging is on, use a version of pcall that warns on failure.
|
||||||
-- This is useful for finding errors that happen within Promise itself.
|
-- This is useful for finding errors that happen within Promise itself.
|
||||||
@@ -89,6 +89,9 @@ function Promise.new(callback)
|
|||||||
-- Only valid if _status is set to something besides Started
|
-- Only valid if _status is set to something besides Started
|
||||||
_value = nil,
|
_value = nil,
|
||||||
|
|
||||||
|
-- If an error occurs with no observers, this will be set.
|
||||||
|
_unhandledRejection = false,
|
||||||
|
|
||||||
-- Queues representing functions we should invoke when we update!
|
-- Queues representing functions we should invoke when we update!
|
||||||
_queuedResolve = {},
|
_queuedResolve = {},
|
||||||
_queuedReject = {},
|
_queuedReject = {},
|
||||||
@@ -157,6 +160,8 @@ end
|
|||||||
The given callbacks are invoked depending on that result.
|
The given callbacks are invoked depending on that result.
|
||||||
]]
|
]]
|
||||||
function Promise:andThen(successHandler, failureHandler)
|
function Promise:andThen(successHandler, failureHandler)
|
||||||
|
self._unhandledRejection = false
|
||||||
|
|
||||||
-- Create a new promise to follow this part of the chain
|
-- Create a new promise to follow this part of the chain
|
||||||
return Promise.new(function(resolve, reject)
|
return Promise.new(function(resolve, reject)
|
||||||
-- Our default callbacks just pass values onto the next promise.
|
-- Our default callbacks just pass values onto the next promise.
|
||||||
@@ -199,6 +204,8 @@ end
|
|||||||
This matches the execution model of normal Roblox functions.
|
This matches the execution model of normal Roblox functions.
|
||||||
]]
|
]]
|
||||||
function Promise:await()
|
function Promise:await()
|
||||||
|
self._unhandledRejection = false
|
||||||
|
|
||||||
if self._status == Promise.Status.Started then
|
if self._status == Promise.Status.Started then
|
||||||
local result
|
local result
|
||||||
local bindable = Instance.new("BindableEvent")
|
local bindable = Instance.new("BindableEvent")
|
||||||
@@ -279,11 +286,22 @@ function Promise:_reject(...)
|
|||||||
-- synchronously. We'll wait one tick, and if there are still no
|
-- synchronously. We'll wait one tick, and if there are still no
|
||||||
-- observers, then we should put a message in the console.
|
-- observers, then we should put a message in the console.
|
||||||
|
|
||||||
local message = ("Unhandled promise rejection:\n\n%s\n\n%s"):format(
|
self._unhandledRejection = true
|
||||||
tostring((...)),
|
local err = tostring((...))
|
||||||
self._source
|
|
||||||
)
|
spawn(function()
|
||||||
warn(message)
|
-- Someone observed the error, hooray!
|
||||||
|
if not self._unhandledRejection then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Build a reasonable message
|
||||||
|
local message = ("Unhandled promise rejection:\n\n%s\n\n%s"):format(
|
||||||
|
err,
|
||||||
|
self._source
|
||||||
|
)
|
||||||
|
warn(message)
|
||||||
|
end)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
237
plugin/src/Reconciler.lua
Normal file
237
plugin/src/Reconciler.lua
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
local Reconciler = {}
|
||||||
|
|
||||||
|
local function isInit(item, itemFileName)
|
||||||
|
if item and item.type == "dir" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
return not not itemFileName:find("^init%.")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function findInit(item)
|
||||||
|
if item.type ~= "dir" then
|
||||||
|
return nil, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
for childFileName, childItem in pairs(item.children) do
|
||||||
|
if isInit(childItem, childFileName) then
|
||||||
|
return childItem, childFileName
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil, nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function itemToName(item, fileName)
|
||||||
|
if item and item.type == "dir" then
|
||||||
|
return fileName, "Folder"
|
||||||
|
elseif item and item.type == "file" or not item then
|
||||||
|
if fileName:find("%.server%.lua$") then
|
||||||
|
return fileName:match("^(.-)%.server%.lua$"), "Script"
|
||||||
|
elseif fileName:find("%.client%.lua$") then
|
||||||
|
return fileName:match("^(.-)%.client%.lua$"), "LocalScript"
|
||||||
|
elseif fileName:find("%.lua") then
|
||||||
|
return fileName:match("^(.-)%.lua$"), "ModuleScript"
|
||||||
|
else
|
||||||
|
return fileName, "StringValue"
|
||||||
|
end
|
||||||
|
else
|
||||||
|
error("unknown item type " .. tostring(item.type))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function setValues(rbx, item, fileName)
|
||||||
|
local _, className = itemToName(item, fileName)
|
||||||
|
|
||||||
|
if className:find("Script") then
|
||||||
|
rbx.Source = item.contents
|
||||||
|
else
|
||||||
|
rbx.Value = item.contents
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Reconciler._reifyShallow(item, fileName)
|
||||||
|
if item.type == "dir" then
|
||||||
|
local initItem, initFileName = findInit(item)
|
||||||
|
|
||||||
|
if initItem then
|
||||||
|
local rbx = Reconciler._reify(initItem, initFileName)
|
||||||
|
rbx.Name = fileName
|
||||||
|
|
||||||
|
return rbx
|
||||||
|
else
|
||||||
|
local rbx = Instance.new("Folder")
|
||||||
|
rbx.Name = fileName
|
||||||
|
|
||||||
|
return rbx
|
||||||
|
end
|
||||||
|
elseif item.type == "file" then
|
||||||
|
local objectName, className = itemToName(item, fileName)
|
||||||
|
|
||||||
|
local rbx = Instance.new(className)
|
||||||
|
rbx.Name = objectName
|
||||||
|
|
||||||
|
setValues(rbx, item, fileName)
|
||||||
|
|
||||||
|
return rbx
|
||||||
|
else
|
||||||
|
error("unknown item type " .. tostring(item.type))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Reconciler._reify(item, fileName, parent)
|
||||||
|
local rbx = Reconciler._reifyShallow(item, fileName)
|
||||||
|
|
||||||
|
if item.type == "dir" then
|
||||||
|
for childFileName, childItem in pairs(item.children) do
|
||||||
|
if not isInit(childItem, childFileName) then
|
||||||
|
local childRbx = Reconciler._reify(childItem, childFileName)
|
||||||
|
childRbx.Parent = rbx
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
rbx.Parent = parent
|
||||||
|
|
||||||
|
return rbx
|
||||||
|
end
|
||||||
|
|
||||||
|
function Reconciler.reconcile(rbx, item, fileName, parent)
|
||||||
|
-- Item was deleted!
|
||||||
|
if not item then
|
||||||
|
if isInit(item, fileName) then
|
||||||
|
if not parent then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Un-usurp parent!
|
||||||
|
local newParent = Instance.new("Folder")
|
||||||
|
newParent.Name = parent.Name
|
||||||
|
|
||||||
|
for _, child in ipairs(parent:GetChildren()) do
|
||||||
|
child.Parent = newParent
|
||||||
|
end
|
||||||
|
|
||||||
|
newParent.Parent = parent.Parent
|
||||||
|
parent:Destroy()
|
||||||
|
|
||||||
|
return
|
||||||
|
else
|
||||||
|
if rbx then
|
||||||
|
rbx:Destroy()
|
||||||
|
end
|
||||||
|
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if item.type == "dir" then
|
||||||
|
-- Folder was created!
|
||||||
|
if not rbx then
|
||||||
|
return Reconciler._reify(item, fileName, parent)
|
||||||
|
end
|
||||||
|
|
||||||
|
local initItem, initFileName = findInit(item)
|
||||||
|
|
||||||
|
if initItem then
|
||||||
|
local _, initClassName = itemToName(initItem, initFileName)
|
||||||
|
|
||||||
|
if rbx.ClassName == initClassName then
|
||||||
|
setValues(rbx, initItem, initFileName)
|
||||||
|
else
|
||||||
|
rbx:Destroy()
|
||||||
|
return Reconciler._reify(item, fileName, parent)
|
||||||
|
end
|
||||||
|
else
|
||||||
|
if rbx.ClassName ~= "Folder" then
|
||||||
|
rbx:Destroy()
|
||||||
|
return Reconciler._reify(item, fileName, parent)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local visitedChildren = {}
|
||||||
|
|
||||||
|
for childFileName, childItem in pairs(item.children) do
|
||||||
|
if not isInit(childItem, childFileName) then
|
||||||
|
local childName = itemToName(childItem, childFileName)
|
||||||
|
|
||||||
|
visitedChildren[childName] = true
|
||||||
|
|
||||||
|
Reconciler.reconcile(rbx:FindFirstChild(childName), childItem, childFileName, rbx)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
for _, childRbx in ipairs(rbx:GetChildren()) do
|
||||||
|
-- Child was deleted!
|
||||||
|
if not visitedChildren[childRbx.Name] then
|
||||||
|
childRbx:Destroy()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return rbx
|
||||||
|
elseif item.type == "file" then
|
||||||
|
if isInit(item, fileName) then
|
||||||
|
-- Usurp our container!
|
||||||
|
local _, className = itemToName(item, fileName)
|
||||||
|
|
||||||
|
if parent.ClassName == className then
|
||||||
|
rbx = parent
|
||||||
|
else
|
||||||
|
rbx = Reconciler._reify(item, fileName, parent.Parent)
|
||||||
|
rbx.Name = parent.Name
|
||||||
|
|
||||||
|
for _, child in ipairs(parent:GetChildren()) do
|
||||||
|
child.Parent = rbx
|
||||||
|
end
|
||||||
|
|
||||||
|
parent:Destroy()
|
||||||
|
end
|
||||||
|
|
||||||
|
setValues(rbx, item, fileName)
|
||||||
|
|
||||||
|
return rbx
|
||||||
|
else
|
||||||
|
if not rbx then
|
||||||
|
return Reconciler._reify(item, fileName, parent)
|
||||||
|
end
|
||||||
|
|
||||||
|
local _, className = itemToName(item, fileName)
|
||||||
|
|
||||||
|
if rbx.ClassName ~= className then
|
||||||
|
rbx:Destroy()
|
||||||
|
return Reconciler._reify(item, fileName, parent)
|
||||||
|
end
|
||||||
|
|
||||||
|
setValues(rbx, item, fileName)
|
||||||
|
|
||||||
|
return rbx
|
||||||
|
end
|
||||||
|
else
|
||||||
|
error("unknown item type " .. tostring(item.type))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Reconciler.reconcileRoute(route, item)
|
||||||
|
local location = game
|
||||||
|
|
||||||
|
for i = 1, #route - 1 do
|
||||||
|
local piece = route[i]
|
||||||
|
local newLocation = location:FindFirstChild(piece)
|
||||||
|
|
||||||
|
if not newLocation then
|
||||||
|
newLocation = Instance.new("Folder")
|
||||||
|
newLocation.Name = piece
|
||||||
|
newLocation.Parent = location
|
||||||
|
end
|
||||||
|
|
||||||
|
location = newLocation
|
||||||
|
end
|
||||||
|
|
||||||
|
local fileName = route[#route]
|
||||||
|
|
||||||
|
local name = itemToName(item, fileName)
|
||||||
|
local rbx = location:FindFirstChild(name)
|
||||||
|
Reconciler.reconcile(rbx, item, fileName, location)
|
||||||
|
end
|
||||||
|
|
||||||
|
return Reconciler
|
||||||
10
rojo.json
Normal file
10
rojo.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "rojo",
|
||||||
|
"servePort": 8000,
|
||||||
|
"partitions": {
|
||||||
|
"plugin": {
|
||||||
|
"path": "plugin/src",
|
||||||
|
"target": "ReplicatedStorage.Rojo Plugin"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -128,7 +128,7 @@ fn main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let vfs = {
|
let vfs = {
|
||||||
let mut vfs = Vfs::new();
|
let mut vfs = Vfs::new(config.clone());
|
||||||
|
|
||||||
for (name, project_partition) in &project.partitions {
|
for (name, project_partition) in &project.partitions {
|
||||||
let path = {
|
let path = {
|
||||||
@@ -158,9 +158,10 @@ fn main() {
|
|||||||
|
|
||||||
{
|
{
|
||||||
let vfs = vfs.clone();
|
let vfs = vfs.clone();
|
||||||
|
let config = config.clone();
|
||||||
|
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
VfsWatcher::new(vfs).start();
|
VfsWatcher::new(config, vfs).start();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
11
src/vfs.rs
11
src/vfs.rs
@@ -5,6 +5,8 @@ use std::io::Read;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use core::Config;
|
||||||
|
|
||||||
/// Represents a virtual layer over multiple parts of the filesystem.
|
/// Represents a virtual layer over multiple parts of the filesystem.
|
||||||
///
|
///
|
||||||
/// Paths in this system are represented as slices of strings, and are always
|
/// Paths in this system are represented as slices of strings, and are always
|
||||||
@@ -21,6 +23,8 @@ pub struct Vfs {
|
|||||||
/// A chronologically-sorted list of routes that changed since the Vfs was
|
/// A chronologically-sorted list of routes that changed since the Vfs was
|
||||||
/// created, along with a timestamp denoting when.
|
/// created, along with a timestamp denoting when.
|
||||||
pub change_history: Vec<VfsChange>,
|
pub change_history: Vec<VfsChange>,
|
||||||
|
|
||||||
|
config: Config,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
@@ -38,11 +42,12 @@ pub enum VfsItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl Vfs {
|
impl Vfs {
|
||||||
pub fn new() -> Vfs {
|
pub fn new(config: Config) -> Vfs {
|
||||||
Vfs {
|
Vfs {
|
||||||
partitions: HashMap::new(),
|
partitions: HashMap::new(),
|
||||||
start_time: Instant::now(),
|
start_time: Instant::now(),
|
||||||
change_history: Vec::new(),
|
change_history: Vec::new(),
|
||||||
|
config,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +145,10 @@ impl Vfs {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_change(&mut self, timestamp: f64, route: Vec<String>) {
|
pub fn add_change(&mut self, timestamp: f64, route: Vec<String>) {
|
||||||
|
if self.config.verbose {
|
||||||
|
println!("Added change {:?}", route);
|
||||||
|
}
|
||||||
|
|
||||||
self.change_history.push(VfsChange {
|
self.change_history.push(VfsChange {
|
||||||
timestamp,
|
timestamp,
|
||||||
route,
|
route,
|
||||||
|
|||||||
@@ -6,17 +6,20 @@ use notify::{DebouncedEvent, RecommendedWatcher, RecursiveMode, Watcher};
|
|||||||
|
|
||||||
use vfs::Vfs;
|
use vfs::Vfs;
|
||||||
use pathext::path_to_route;
|
use pathext::path_to_route;
|
||||||
|
use core::Config;
|
||||||
|
|
||||||
pub struct VfsWatcher {
|
pub struct VfsWatcher {
|
||||||
vfs: Arc<Mutex<Vfs>>,
|
vfs: Arc<Mutex<Vfs>>,
|
||||||
watchers: Vec<RecommendedWatcher>,
|
watchers: Vec<RecommendedWatcher>,
|
||||||
|
config: Config,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VfsWatcher {
|
impl VfsWatcher {
|
||||||
pub fn new(vfs: Arc<Mutex<Vfs>>) -> VfsWatcher {
|
pub fn new(config: Config, vfs: Arc<Mutex<Vfs>>) -> VfsWatcher {
|
||||||
VfsWatcher {
|
VfsWatcher {
|
||||||
vfs,
|
vfs,
|
||||||
watchers: Vec::new(),
|
watchers: Vec::new(),
|
||||||
|
config,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,6 +43,7 @@ impl VfsWatcher {
|
|||||||
|
|
||||||
{
|
{
|
||||||
let vfs = self.vfs.clone();
|
let vfs = self.vfs.clone();
|
||||||
|
let config = self.config.clone();
|
||||||
|
|
||||||
thread::spawn(move || {
|
thread::spawn(move || {
|
||||||
loop {
|
loop {
|
||||||
@@ -47,6 +51,10 @@ impl VfsWatcher {
|
|||||||
let mut vfs = vfs.lock().unwrap();
|
let mut vfs = vfs.lock().unwrap();
|
||||||
let current_time = vfs.current_time();
|
let current_time = vfs.current_time();
|
||||||
|
|
||||||
|
if config.verbose {
|
||||||
|
println!("FS event {:?}", event);
|
||||||
|
}
|
||||||
|
|
||||||
match event {
|
match event {
|
||||||
DebouncedEvent::Write(ref change_path) |
|
DebouncedEvent::Write(ref change_path) |
|
||||||
DebouncedEvent::Create(ref change_path) |
|
DebouncedEvent::Create(ref change_path) |
|
||||||
|
|||||||
Reference in New Issue
Block a user